#!/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','elements','fluids','optics','waves','thermodynamics','thermalphysics')}
text = '\n'.join(sources.values())
names = declarations(text,'tikzphysics@registerdisplayname')
pic_anchors = declarations(text,'tikzphysics@registeranchors')
keys = declarations(text,'tikzphysics@registerkeydefaults')
registered_families = declarations(text,'tikzphysics@registerfamilies')
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','physicsdifferentialsector':'physicspolarelement','physicsdifferentialring':'physicspolarelement','physicssphericalshell':'physicspolarelement','physicshollowsphere':'physicspolarelement','physicsrectangularstrip':'physicsrectangularelement','physicsrectangularsheet':'physicsrectangularelement','physicsconcavemirror':'physicsmirror','physicsconvexmirror':'physicsmirror'}
actual.update({'physicsplanemirror': 'physicsvariantlens', 'physicsplanoconvexlens': 'physicsvariantlens', 'physicsplanoconcavelens': 'physicsvariantlens', 'physicspositivemeniscuslens': 'physicsvariantlens', 'physicsnegativemeniscuslens': 'physicsvariantlens'})
# Path/pic reference declarations share the same registries as objects.
no_node = {'physicsrope':'path','physicsforce':'path','physicsvelocity':'path','physicsacceleration':'path','physicstorque':'path','physicsrod':'path','physicspinsupport':'pic','physicsrollersupport':'pic','physicspendulum':'pic','physicsdifferentialringdiagram':'pic','physicssphereshelldiagram':'pic','physicssphereslicediagram':'pic','physicshollowspherediagram':'pic','physicscylindershelldiagram':'pic','physicscylinderslicediagram':'pic','physicsconeslicediagram':'pic','physicssheetelementdiagram':'pic'}
no_node.update({'physicsutube':'pic','physicsgasmanometer':'pic','physicshydraulicpress':'pic','physicsfluidcylinder':'pic','physicsfluidtank':'pic','physicspressureelement':'pic','physicsrotatingtube':'pic','physicsrotatingfluid':'pic','physicsbuoyancy':'pic','physicsflowtube':'pic','physicsmeniscus':'pic','physicscapillary':'pic','physicssurfacetensionring':'pic','physicsliquidring':'pic'})
no_node.update({'physicsheatenginediagram':'pic','physicsrefrigeratordiagram':'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 = {
 'physicsplatformleft':'surface runs left to right across the floor; bottom, left, and right cover its other edges. wall-surface and wall-back run from root to tip; wall-base and wall-tip span the end caps. transition runs from pulley-center to wall-root. Upward walls require zero inset and drop. Use 50 for any edge midpoint.',
 'physicsplatformright':'surface runs left to right across the floor; bottom, left, and right cover its other edges. wall-surface and wall-back run from root to tip; wall-base and wall-tip span the end caps. transition runs from pulley-center to wall-root. Upward walls require zero inset and drop. Use 50 for any edge midpoint.',
 'physicsplatformboth':'surface runs left to right across the floor. Each wall has surface, back, base, and tip families with left-wall or right-wall prefixes; each transition runs from its pulley-center to wall-root. Upward walls require zero inset and drop. Use 50 for any edge midpoint.',
 'physicsground':'surface runs left to right on the top contact face; bottom, left, and right cover the other edges. Use 50 for an edge midpoint.',
 'physicsceiling':'surface runs left to right on the underside; top, left, and right cover the other edges. Use 50 for an edge midpoint.',
 'physicswallleft':'surface runs bottom to top on the right contact face; top, bottom, and left cover the other edges. Use 50 for an edge midpoint.',
 'physicswallright':'surface runs bottom to top on the left contact face; top, bottom, and right cover the other edges. Use 50 for an edge midpoint.',
 'physicswedge':'base, right, and slope follow the outline counterclockwise from bl. surface runs left to right on the primary usable incline; in top mode right-surface selects the second incline. right-mid equals right-50; slope-mid equals surface-50. A pulley edge is supported in br and bl modes, and its centroid follows the filled four-point body. Directions and normals are local to the node.',
 'physicsramp':'surface follows floor plus incline by distance; floor and incline select each component. wall-surface, wall-back, wall-tip, base, and end cover the remaining outline. At the sharp joint the tangent uses the incline side.',
 'physicscurvedramp':'surface follows floor plus arc by distance; curve follows the arc only. floor, start, base, back, and top cover the remaining boundary.',
 'physicsspring':'Use draw[spring] (A)--(B) for a two-point connection, or node[spring] (S) {} for a positioned coil. Node length defaults to 3cm; spring length and minimum width set its length. start/end are exact axis attachments, coil-start/coil-end delimit the leads, and axis-0..100 samples the straight axis, not the coil wire. show anchors/show keys apply to nodes; physicshelp{spring} describes both forms. Lead lengths may be zero; their sum must be smaller than the node length. 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).',
 'physicspolarelement':'An annular sector from inner-start to outer-end. Set inner radius to zero for a sector or delta angle to 360 degrees for a full ring.',
 'physicsdifferentialsector':'A polar-element preset with zero inner radius. The outer arc represents r d theta and numeric families follow every boundary.',
 'physicsdifferentialring':'A polar-element preset with a 360-degree sweep. Use source element on an unwrapped ring to copy its radii.',
 'physicsunwrappedring':'A differential approximation with width 2 pi times the inner reference radius and height equal to the radial thickness. source element copies both radii from a named polar element.',
 'physicsdifferentialringdiagram':'Use pic (D) {differential ring diagram={...}}. It keeps the body circle, ring, centre mark, and unwrapped strip together. Components are (D-ring) and (D-strip).',
 'physicssphericalshell':'A concentric differential shell shown in section. Its full-sweep anchors and radial geometry match the spherical-shell construction.',
 'physicshollowsphere':'A finite spherical wall shown in section, with independent inner and outer radii.',
 'physicsrectangularelement':'A Cartesian differential area with independent width and height, complete boundary anchors, and optional dimension labels.',
 'physicsrectangularstrip':'A full-width strip preset. Change its width and differential thickness independently.',
 'physicsrectangularsheet':'A finite unshaded sheet node used alone or as the body in a sheet element diagram.',
 'physicssphereshelldiagram':'A solid sphere with a highlighted spherical shell. Components are (S-body) and (S-shell); (S-center) marks the common centre.',
 'physicshollowspherediagram':'A hollow sphere in section. Component (H-wall) exposes the inner and outer boundaries.',
 'physicssphereslicediagram':'A solid sphere with a projected disk slice at a signed normalized axial position. Components are (S-body) and (S-slice).',
 'physicscylindershelldiagram':'A projected cylinder with a radial cylindrical shell. Components are (C-body) and (C-shell).',
 'physicscylinderslicediagram':'A projected cylinder with an axial disk slice. Components are (C-body) and (C-slice).',
 'physicsconeslicediagram':'A projected cone with a thin disk slice. element position is measured from apex to base. Components are (K-body) and (K-slice).',
 'physicssheetelementdiagram':'A rectangular sheet with a named Cartesian element. Components are (A-body) and (A-element).',
}

notes.update({
 'physicsblock':'bottom, right, top, left follow the boundary counterclockwise. Use 0 for an edge start, 50 for its midpoint, 100 for its end.',
 **{n:'rim starts at the rightmost point and runs counterclockwise around the circle. 25 is north, 50 west, 75 south, and 100 returns to the start.' for n in ('physicspulley','physicsparticle','physicsdisk','physicsring')},
 'physicsfluidtanknode':'surface runs left to right. bottom, right, left and the two rounded bottom corners follow the drawn outline counterclockwise.',
 'physicsmeniscusnode':'surface follows the cubic liquid boundary from left to right. bottom, right and left follow the vessel boundary counterclockwise.',
 'physicsrotatingfluidnode':'surface follows the parabola from left to right. bottom, right and left follow the vessel boundary counterclockwise.',
 'physicsfluidcylindernode':'surface is the left-to-right diameter of the liquid ellipse; surface-rim, top and bottom are full ellipse boundaries, starting at the rightmost point and running counterclockwise. left and right cover the straight walls.',
 'physicspressureelementnode':'inlet and outlet run bottom to top across their projected faces. inlet-rim and outlet-rim run counterclockwise from the rightmost point. axis joins their centres left to right; top and bottom cover the straight outline.',
 'physicsflowtubenode':'inlet and outlet run bottom to top; axis follows their curved midline left to right. bottom runs left to right, top right to left on the actual cubic outline. Curve percentages use the cubic parameter.',
 'physicsliquidringnode':'outer and inner run counterclockwise from the rightmost point. liquid follows the filled sector centreline from liquid-start to liquid-end; start and end run radially from inner to outer boundary.',
 'physicsutubenode':'left-surface and right-surface independently run left to right across the liquid in each arm. surface selects the left arm, never the empty gap. outer and inner follow the U outline by distance from the left lip down through the bend to the right lip.',
})

notes.update({'physicsutube': 'Two-liquid U tube. level adds a second liquid only above left level. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicsgasmanometer': 'Gas reservoir connected to a U tube. body label names the gas. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicshydraulicpress': 'Large piston width is 0.32 times width; tube width sets the small piston. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicsfluidcylinder': 'Projected cylinder with an elliptical liquid surface. width is the diameter. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicsfluidtank': 'Open vessel. Equal endpoint levels give a horizontal free surface. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicspressureelement': 'Projected horizontal fluid element with inward pressure forces. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicsrotatingtube': 'Horizontal tube with a differential section. level locates its left face along width. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicsrotatingfluid': 'Parabolic surface. level is the vertex height; bend is the wall-to-vertex rise divided by height. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicsbuoyancy': 'Immersed body with schematic inward pressure arrows. Arrow lengths do not encode pressure magnitude. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicsflowtube': 'Curved flow tube; outlet height is 0.55 times inlet height. bend controls vertical curvature. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicsmeniscus': 'Positive bend is concave; negative bend is convex. Contact angle is measured through the liquid. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicscapillary': 'Capillary rise. level is the reservoir surface and left level is the contact-line height. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicssurfacetensionring': 'Projected circular contact line. width and height are the ellipse diameters. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.', 'physicsliquidring': 'Annular liquid sector. width is outer diameter, tube width is radial thickness. Angles use degrees counterclockwise from right. Use (F-origin), (F-center), and the listed (F-...) coordinates; these are pic coordinates, not node anchors.'})

# Native component directions and physical scope.
notes.update({'physicsbeamnode': 'axis runs left to right; boundary families run counterclockwise. rod node preserves the '
                    'existing rod path style.',
 'physicscapillarytubenode': 'surface runs left to right by horizontal position. Positive sag gives a '
                             'concave meniscus; negative sag a convex meniscus. Heights are illustrative, '
                             'not computed from surface tension.',
 'physicscircularbowlnode': 'surface follows the lower semicircle left to right. Tangent and '
                            'inward/left-normal guide points are 0.2 cm from contact. Optional rough '
                            'interval is a percentage range marked with hatching.',
 'physicsclosedpipenode': 'Displacement pattern: open ends are antinodes, the closed left end is a node. '
                          'mode selects harmonics; closed-pipe harmonics are odd. node-N and antinode-N are '
                          'zero-based ordinal locations on the axis, not percentages.',
 'physicscompoundpulleynode': 'Two concentric drums share one axle. Both rims start at the rightmost point '
                              'and run counterclockwise. This is a stepped pulley, not an automatically '
                              'routed tackle.',
 'physicsconicalshellnode': 'Projected wireframe: rims start at the right and run counterclockwise; axis and '
                            'right generator run bottom to top. Depth is ellipse compression, not a 3D '
                            'camera. Conical shell is a hollow frustum; thickness is radial at the base and '
                            'scales with top ratio.',
 'physicscylindricalshellnode': 'Projected wireframe: rims start at the right and run counterclockwise; axis '
                                'and right generator run bottom to top. Depth is ellipse compression, not a '
                                '3D camera.',
 'physicsdampernode': 'piston runs bottom to top; position is a visual stroke fraction, not a force law.',
 'physicshemispherenode': 'Upper hemisphere in wireframe projection; meridian runs left to right over the '
                          'dome, rim counterclockwise, axis from base centre to apex.',
 'physicslayeredtanknode': 'Two liquid layers; interface and surface run left to right. Separate centre '
                           'anchors support density labels without color.',
 'physicslongitudinalwavenode': 'Axis runs left to right; curve is a snapshot, not a time simulation. curve '
                                'follows a schematic variable-spacing spring; compression below 1 prevents '
                                'reversal of the longitudinal coordinate.',
 'physicslooptracknode': 'surface starts at the bottom and runs counterclockwise around the full loop. '
                         'Tangent and inward/left-normal guide points are 0.2 cm from contact. Optional '
                         'rough interval is a percentage range marked with hatching.',
 'physicsnozzlenode': 'Wall families run inlet to outlet by axial position. Cross-sections run bottom to '
                      'top. Cosine profiles join smoothly; flow is not solved.',
 'physicsopenpipenode': 'Displacement pattern: open ends are antinodes, the closed left end is a node. mode '
                        'selects harmonics; closed-pipe harmonics are odd. node-N and antinode-N are '
                        'zero-based ordinal locations on the axis, not percentages.',
 'physicsphysicalpendulumnode': 'Uniform rigid bar. axis runs top to bottom; pivot is a fraction from the '
                                'top. Use anchor=pivot and rotate to suspend it.',
 'physicspistoncylindernode': 'Piston family is the lower gas-contact face, left to right; position is '
                              'measured from the bottom. Cylinder top is open.',
 'physicssolidconenode': 'Projected wireframe: rims start at the right and run counterclockwise; axis and '
                         'right generator run bottom to top. Depth is ellipse compression, not a 3D camera.',
 'physicssolidcylindernode': 'Projected wireframe: rims start at the right and run counterclockwise; axis '
                             'and right generator run bottom to top. Depth is ellipse compression, not a 3D '
                             'camera.',
 'physicssolidfrustumnode': 'Projected wireframe: rims start at the right and run counterclockwise; axis and '
                            'right generator run bottom to top. Depth is ellipse compression, not a 3D '
                            'camera.',
 'physicsstandingwavenode': 'Axis runs left to right; curve is a snapshot, not a time simulation. Fixed '
                            'endpoints. lobes controls half-wavelengths; node-N indexes 0..lobes and '
                            'antinode-N indexes 0..lobes-1 on the axis.',
 'physicstracknode': 'surface is distance along the three joined straight segments, left to right. At joints '
                     'tangent uses the outgoing section. Connect another node with anchor=start at the '
                     'preceding end. Tangent and inward/left-normal guide points are 0.2 cm from contact. '
                     'Optional rough interval is a percentage range marked with hatching.',
 'physicstransversewavenode': 'Axis runs left to right; curve is a snapshot, not a time simulation.',
 'physicsventurinode': 'Wall families run inlet to outlet by axial position. Cross-sections run bottom to '
                       'top. Cosine profiles join smoothly; flow is not solved.'})

notes.update({'physicspvdiagramnode': 'Empty PV axes frame. Overlay process nodes at the same center with matching axis keys and pv show axes=false; the axes and projection anchors remain available.',
 'physicsadiabaticprocessnode': 'process advances linearly in volume from the start to end state; '
                                'compression is supported. PV values are unitless numbers in '
                                'consistent author-chosen units. Axis maxima must contain both '
                                'endpoints. No heat or work integral is computed. This is the '
                                'reversible ideal-gas relation pressure times volume to the gamma power is constant with gamma > '
                                '1.',
 'physicscarnotcyclenode': 'Reversible ideal-gas Carnot cycle: hot A-B isotherm, B-C adiabatic '
                           'expansion, cold C-D isotherm, D-A adiabatic compression. Cold/hot is '
                           'an absolute-temperature ratio; hot constant is P_A V_A. Branch '
                           'percentages interpolate volume, not arc length. States are derived to '
                           'close exactly.',
 'physicsgaschambernode': 'Physical boundaries run counterclockwise from bottom-left. Heat ports '
                          'lie at face midpoints. Geometry is schematic; text and temperatures are '
                          'supplied by the author.',
 'physicsheatenginediagram': 'Heat enters from the hot reservoir, part leaves as work, and the '
                             'remainder reaches the cold reservoir. Named child nodes use the pic '
                             'prefix. Flow coordinate families heat-hot, heat-cold and work run from arrow tail to head; use the pic prefix and integer percentages.',
 'physicsheatenginenode': 'hot is the upper port, cold the lower port, work the right port. '
                          'heat-in and heat-out follow the device role. Arrow directions are '
                          'supplied by the named diagram pic or by the author.',
 'physicsisobaricprocessnode': 'process advances linearly in volume from the start to end state; '
                               'compression is supported. PV values are unitless numbers in '
                               'consistent author-chosen units. Axis maxima must contain both '
                               'endpoints. No heat or work integral is computed.',
 'physicsisochoricprocessnode': 'process advances linearly in pressure at fixed volume. PV values '
                                'are unitless numbers in consistent author-chosen units. Axis '
                                'maxima must contain both endpoints. No heat or work integral is '
                                'computed.',
 'physicsisothermalprocessnode': 'process advances linearly in volume from the start to end state; '
                                 'compression is supported. PV values are unitless numbers in '
                                 'consistent author-chosen units. Axis maxima must contain both '
                                 'endpoints. No heat or work integral is computed.',
 'physicspolytropicprocessnode': 'process advances linearly in volume from the start to end state; '
                                 'compression is supported. PV values are unitless numbers in '
                                 'consistent author-chosen units. Axis maxima must contain both '
                                 'endpoints. No heat or work integral is computed.',
 'physicsrectangularcyclenode': 'Clockwise A-B-C-D cycle. Branch percentages are linear in the '
                                'changing coordinate. A is low-volume low-pressure; B '
                                'high-pressure; C high-volume high-pressure; D low-pressure.',
 'physicsrefrigeratordiagram': 'Work enters the device, heat is absorbed from the cold reservoir, '
                               'and heat is rejected to the hot reservoir. Labels are magnitudes '
                               'supplied by the author.',
 'physicsrefrigeratornode': 'hot is the upper port, cold the lower port, work the right port. '
                            'heat-in and heat-out follow the device role. Arrow directions are '
                            'supplied by the named diagram pic or by the author.',
 'physicsthermalreservoirnode': 'Physical boundaries run counterclockwise from bottom-left. Heat '
                                'ports lie at face midpoints. Geometry is schematic; text and '
                                'temperatures are supplied by the author.',
 'physicsthermalwallnode': 'Physical boundaries run counterclockwise from bottom-left. Heat ports '
                           'lie at face midpoints. Geometry is schematic; text and temperatures '
                           'are supplied by the author.'})

def clean(v):
    return v.replace('$','').replace('^\\circ',' degrees')
notes.update({'physicsconductionslabnode': 'Boundary families run counterclockwise; axis runs left to right. Heat flux and temperatures are author annotations; no conductivity law is solved.', 'physicscompositewallnode': 'Boundary families run counterclockwise; axis runs left to right. Heat flux and temperatures are author annotations; no conductivity law is solved.', 'physicsconvectionsurfacenode': 'surface runs left to right on the plate-fluid interface. flow follows the central schematic heat-transfer arrow, outward for +1 and inward for -1. Arrow lengths do not encode h or heat flux.', 'physicscoolingfinnode': 'One extended fin attached to a base. axis runs root to tip; fin boundary follows bottom, tip and top counterclockwise. Fin length includes base thickness. Temperatures and distributed convection are author annotations.', 'physicsradiatingbodynode': 'rim runs counterclockwise from the rightmost point. ray follows the rightward emission arrow or the inward absorption arrow. Rays are schematic and do not solve a Stefan-Boltzmann balance.', 'physicsthermometernode': 'level is a geometric stem fraction, not a calibrated temperature. scale runs from bulb top toward stem cap; column runs from bulb centre to liquid-top. bulb follows the exposed outline counterclockwise from the left stem join through the bottom to the right stem join. Stem sides and cap expose the remaining boundary.', 'physicscalorimeternode': 'Double-wall calorimeter with schematic insulation spacing and a liquid surface. Boundary families run counterclockwise; lid and surface run left to right. No heat capacity or mixing temperature is computed.', 'physicsexpansionrodnode': 'Original rod is below; changed rod above. Both families run left to right along their axes with a shared left reference. Ratio sets changed/original length; values below one show contraction. Strain is supplied by the author.'})

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. Use `show keys` or `show anchors` on a node; a name is optional for the overlay.','', 'All objects support ordinary TikZ styling; see the manual for transformation and sizing limitations. Percentage families accept integers 0 through 100.','']
md += ['Start with the [debug overlay guide](debug-overlays.md) for `show anchors`, `show keys`, and `\\physicshelp` examples across nodes, paths, and pics. For boundary directions, see the [surface-anchor guide](surface-anchors.md), [wedge-anchor guide](wedge-anchors.md), and [ramp-anchor guide](ramp-anchors.md). Each has a rendered `show anchors` gallery.', '']
manual = []
for logical, name in names.items():
    kind = no_node.get(logical,'node')
    kind_label = 'node and path' if logical == 'physicsspring' else kind
    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|surface@declarewedgeboundaryanchors|elements@declarearcanchors|elements@declareedgeanchors)\{([^}]+)\}',body)
    families+=extra_families.get(logical,[])
    families+=[f.strip() for f in registered_families.get(logical,'').split(',') if f.strip()]
    families=list(dict.fromkeys(families))
    if kind == 'pic':
        if logical in pic_anchors: anchors=pic_anchors[logical].split(',')
        elif logical == 'physicspendulum': anchors=['pivot','bob (node)']
        elif logical == 'physicsdifferentialringdiagram': anchors=['center','body-north','body-south','body-east','body-west','ring (node)','strip (node)']
        elif logical == 'physicssphereshelldiagram': anchors=['center','body (node)','shell (node)','formula-anchor']
        elif logical == 'physicshollowspherediagram': anchors=['center','body-north','body-south','wall (node)','formula-anchor']
        elif logical == 'physicssphereslicediagram': anchors=['center','body (node)','slice (node)','formula-anchor']
        elif logical in ('physicscylindershelldiagram','physicscylinderslicediagram'): anchors=['center','body (node)','shell or slice (node)','formula-anchor']
        elif logical == 'physicsconeslicediagram': anchors=['center','apex','base','body (node)','slice (node)','formula-anchor']
        elif logical == 'physicssheetelementdiagram': anchors=['center','body (node)','element (node)','formula-anchor']
        else: anchors=['pivot','base','left','right']
    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={'physicsspring':'spring','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') or shape == 'physicsvariantlens':
        familytext+= '; legacy .0..100 shorthand also available'
    if shape in ('circle','rectangle'):
        familytext += ('; ' if familytext else '') + 'Bare numeric anchors remain 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)
    if kind == 'pic' and logical in pic_anchors and hook.endswith(' diagram') and logical not in ('physicsheatenginediagram', 'physicsrefrigeratordiagram'): hook = hook[:-8]
    contents=[r'\textbf{'+tex(name)+' ('+kind_label+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]
    thermo_aliases = {'physicsthermalreservoirnode':['hot reservoir','cold reservoir'], 'physicsthermalwallnode':['conducting wall','insulated wall'], 'physicsrefrigeratornode':['heat pump'], 'physicsrefrigeratordiagram':['heat pump diagram']}
    lookup += thermo_aliases.get(logical, [])
    lookup += {'physicsradiatingbodynode':['radiation source'], 'physicsexpansionrodnode':['thermal expansion rod']}.get(logical, [])
    if kind in ('path','pic') or logical == 'physicsspring':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_label+')','','| 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_label+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}\par\smallskip']
    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'))
