from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.data_types import DataField @register_node(display_name="Tip Model") class TipModel: @classmethod def INPUT_TYPES(cls): return { "required": { "field": ("DATA_FIELD",), "shape": (["parabola", "cone", "sphere"], {"default": "parabola"}), "radius": ("FLOAT", { "default": 10e-9, "min": 1e-10, "max": 1e-6, "step": 1e-10, }), "half_angle": ("FLOAT", { "default": 20.0, "min": 1.0, "max": 89.0, "step": 0.5, }), "n_pixels": ("INT", {"default": 65, "min": 3, "max": 511, "step": 2}), } } OUTPUTS = ( ('DATA_FIELD', 'tip'), ) FUNCTION = "process" DESCRIPTION = ( "Generate a synthetic AFM tip model DATA_FIELD. " "The input field sets the pixel size for the tip. " "The apex (centre pixel) is the maximum value; edges are shifted to zero. " "Shapes: parabola — paraboloid with apex radius R; " "cone — sphere-capped cone (radius R, half_angle from tip axis in degrees); " "sphere — ball-on-stick (sphere cap only). " ) KEYWORDS = ("apex", "parabola", "cone", "sphere", "synthetic", "probe", "cantilever") def process( self, field: DataField, shape: str, radius: float, half_angle: float, n_pixels: int, ) -> tuple: pixel_size = (field.dx + field.dy) * 0.5 # Ensure odd size so the centre pixel is well-defined n = n_pixels if n_pixels % 2 == 1 else n_pixels + 1 ci = n // 2 # Physical offsets from the centre pixel offsets = (np.arange(n) - ci) * pixel_size gx, gy = np.meshgrid(offsets, offsets) r2 = gx**2 + gy**2 r = np.sqrt(r2) if shape == "parabola": # Gwyddion parabola(): a = 1/(2R), z0 = 2a·x_half² # z[y,x] = z0 - a·r² → min at corners is exactly 0 a = 0.5 / radius x_half = ci * pixel_size # half-width in physical units z0 = 2.0 * a * x_half**2 data = z0 - a * r2 data -= data.min() # shift so min = 0 elif shape == "cone": # Gwyddion cone(): # angle = half-angle from horizontal = π/2 - half_angle_from_axis # z0 = R/sin(angle) # r_cross² = (R·cos(angle))² # inner (r² < r_cross²): z = sqrt(R² - r²) ← spherical cap # outer: z = z0 - r/tan(angle) angle = np.radians(90.0 - half_angle) # slope from horizontal z0 = radius / np.sin(angle) br2 = (radius * np.cos(angle))**2 ta = 1.0 / np.tan(angle) inner = np.sqrt(np.maximum(0.0, radius**2 - r2)) outer = z0 - ta * r data = np.where(r2 < br2, inner, outer) data -= data.min() elif shape == "sphere": # Gwyddion sphere(): cone with near-vertical sides (angle ≈ 0 from horizontal) angle = 1e-5 # radians — essentially vertical cone z0 = radius / np.sin(angle) br2 = (radius * np.cos(angle))**2 ta = 1.0 / np.tan(angle) inner = np.sqrt(np.maximum(0.0, radius**2 - r2)) outer = z0 - ta * r data = np.where(r2 < br2, inner, outer) data -= data.min() else: raise ValueError(f"Unknown tip shape {shape!r}. Choose: parabola, cone, sphere.") xreal = n * pixel_size tip_field = DataField( data=data, xreal=xreal, yreal=xreal, si_unit_xy=field.si_unit_xy, si_unit_z=field.si_unit_z, ) return (tip_field,)