diff --git a/BACKLOG.md b/BACKLOG.md index 9ecc5b26..75bc1cf0 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -186,7 +186,7 @@ Description formats: | 027 | Infrastructure | [Add automated screenshot capture for Storybook stories](docs/ideas/027-automated-screenshots.md) | 3 | 4 | 4 | 11 | Medium | approved | | 088 | Feature | [Custom editor provider for result datasets](docs/ideas/E04-results-visualization.md) [E04] — opens results as editor tabs, supports drag-to-float via auxiliary windows (requires #085) | 3 | 4 | 4 | 11 | Medium | approved | | 005 | Tech Debt | Add cross-service end-to-end workflow tests (io -> stac -> calc) | 4 | 2 | 5 | 11 | Low | approved | -| 057 | Feature | [Add enlarge shape tool spec](docs/ideas/057-enlarge-shape.md) (requires #049) | 3 | 3 | 5 | 11 | Low | approved | +| 057 | Feature | [Add enlarge shape tool spec](specs/057-enlarge-shape/spec.md) (requires #049) | 3 | 3 | 5 | 11 | Low | specified | | 078 | Feature | [Implement generate-reference-points tool](docs/ideas/E03-buffer-zone-analysis-demo.md) [E03] — creates grid/scatter of reference points on plot (requires #049) | 3 | 3 | 5 | 11 | Low | approved | | 008 | Feature | Design and implement extension discovery mechanism for contrib packages | 4 | 3 | 3 | 10 | High | approved | | 090 | Infrastructure | [E04 sample data workshop: identify realistic generators for all result types](docs/ideas/090-e04-sample-data-workshop.md) [E04] — collaborative workshop using E03 demo scenario; pseudocode + golden fixtures for all result categories (prerequisite for #085) | 5 | 3 | 2 | 10 | Low | approved | diff --git a/apps/vscode/src/tools/shape/manipulation/enlargeShape.ts b/apps/vscode/src/tools/shape/manipulation/enlargeShape.ts new file mode 100644 index 00000000..6dc4a8eb --- /dev/null +++ b/apps/vscode/src/tools/shape/manipulation/enlargeShape.ts @@ -0,0 +1,197 @@ +/** + * Enlarge Shape tool implementation. + * Scales annotation shapes by a multiplicative factor relative to an origin point. + * + * Spec: shared/tools/shape/manipulation/enlarge-shape.1.0.md + */ + +import type { MCPToolDefinition } from '../../../types/tool'; + +export interface EnlargeShapeParams { + scale_factor?: number; + origin?: number[] | null; +} + +export const toolDefinition: MCPToolDefinition = { + name: 'enlarge-shape', + description: + 'Scales annotation shapes by a multiplicative factor relative to an origin point. Uses linear interpolation of geographic coordinate differences.', + inputSchema: { + type: 'object', + properties: { + features: { type: 'array', items: { type: 'object' } }, + params: { + type: 'object', + properties: { + scale_factor: { + type: 'number', + description: 'Multiplicative scaling factor (>1 enlarges, <1 shrinks, 1 is no-op)', + default: 3.0, + }, + origin: { + type: 'array', + description: 'Scaling origin as [longitude, latitude]. Default: geometric centroid.', + items: { type: 'number' }, + }, + }, + }, + }, + }, + annotations: { + 'debrief:selectionRequirements': [ + { kind: 'CIRCLE', min: 1 }, + { kind: 'RECTANGLE', min: 1 }, + { kind: 'LINE', min: 1 }, + { kind: 'TEXT', min: 1 }, + { kind: 'VECTOR', min: 1 }, + ], + 'debrief:category': 'shape/manipulation', + 'debrief:version': '1.0.0', + 'debrief:outputKind': 'mutation/shape/scaled', + }, +}; + +interface GeoJSONFeature { + type: 'Feature'; + id?: string; + geometry: { type: string; coordinates: unknown }; + properties: Record; +} + +const ANNOTATION_KINDS = new Set(['CIRCLE', 'RECTANGLE', 'LINE', 'TEXT', 'VECTOR']); + +/** + * Compute arithmetic mean of vertices as the geometric centroid. + */ +function computeCentroid(geometry: { type: string; coordinates: unknown }): number[] { + const geoType = geometry.type; + const coords = geometry.coordinates; + + if (geoType === 'Point') { + return [...(coords as number[])]; + } + + let vertices: number[][]; + + if (geoType === 'Polygon') { + const ring = (coords as number[][][])[0]!; + // Exclude closing vertex if first == last + if ( + ring.length > 1 && + ring[0]![0] === ring[ring.length - 1]![0] && + ring[0]![1] === ring[ring.length - 1]![1] + ) { + vertices = ring.slice(0, -1); + } else { + vertices = ring; + } + } else if (geoType === 'LineString') { + vertices = coords as number[][]; + } else { + vertices = coords as number[][]; + } + + const n = vertices.length; + if (n === 0) return [0, 0]; + + let sumLon = 0; + let sumLat = 0; + for (const v of vertices) { + sumLon += v[0]!; + sumLat += v[1]!; + } + + return [sumLon / n, sumLat / n]; +} + +/** + * Scale a single [lon, lat] coordinate relative to an origin. + */ +function scaleCoordinate( + coord: number[], + origin: number[], + scaleFactor: number, +): number[] { + let newLon = origin[0]! + (coord[0]! - origin[0]!) * scaleFactor; + let newLat = origin[1]! + (coord[1]! - origin[1]!) * scaleFactor; + + // Clamp latitude to [-90, 90] + newLat = Math.max(-90, Math.min(90, newLat)); + + // Normalise longitude to [-180, 180] + newLon = ((newLon + 180) % 360) - 180; + + return [newLon, newLat]; +} + +function scaleCoordsList( + coords: number[][], + origin: number[], + scaleFactor: number, +): number[][] { + return coords.map((c) => scaleCoordinate(c, origin, scaleFactor)); +} + +export function execute( + features: GeoJSONFeature[], + params: EnlargeShapeParams, +): GeoJSONFeature[] { + const scaleFactor = params.scale_factor ?? 3.0; + const origin = params.origin ?? null; + + if (scaleFactor < 0) { + throw new Error('scale_factor must be >= 0'); + } + + const modified: GeoJSONFeature[] = []; + + for (const feature of features) { + const props = feature.properties ?? {}; + const kind = props.kind as string; + + if (!ANNOTATION_KINDS.has(kind)) { + continue; + } + + const geometry = feature.geometry; + + // Determine scaling origin + const scalingOrigin = origin ?? computeCentroid(geometry); + + // Scale factor of 1.0 is a no-op + if (scaleFactor === 1.0) { + modified.push(feature); + continue; + } + + const coords = geometry.coordinates; + + if (kind === 'CIRCLE' || kind === 'RECTANGLE') { + // Polygon: scale all rings + const polyCoords = coords as number[][][]; + geometry.coordinates = polyCoords.map((ring) => + scaleCoordsList(ring, scalingOrigin, scaleFactor), + ); + if (kind === 'CIRCLE' && props.center) { + props.center = scaleCoordinate(props.center as number[], scalingOrigin, scaleFactor); + } + } else if (kind === 'LINE') { + geometry.coordinates = scaleCoordsList(coords as number[][], scalingOrigin, scaleFactor); + } else if (kind === 'TEXT') { + geometry.coordinates = scaleCoordinate(coords as number[], scalingOrigin, scaleFactor); + } else if (kind === 'VECTOR') { + geometry.coordinates = scaleCoordsList(coords as number[][], scalingOrigin, scaleFactor); + if (props.origin) { + props.origin = scaleCoordinate(props.origin as number[], scalingOrigin, scaleFactor); + } + } + + modified.push(feature); + } + + if (modified.length === 0) { + throw new Error('No annotation features found in input'); + } + + return modified; +} diff --git a/services/calc/debrief_calc/tools/shape/manipulation/__init__.py b/services/calc/debrief_calc/tools/shape/manipulation/__init__.py index 83da9282..33e8fb61 100644 --- a/services/calc/debrief_calc/tools/shape/manipulation/__init__.py +++ b/services/calc/debrief_calc/tools/shape/manipulation/__init__.py @@ -1,7 +1,9 @@ """Shape manipulation tools.""" +from debrief_calc.tools.shape.manipulation.enlarge_shape import enlarge_shape from debrief_calc.tools.shape.manipulation.move_shape import move_shape __all__ = [ + "enlarge_shape", "move_shape", ] diff --git a/services/calc/debrief_calc/tools/shape/manipulation/enlarge_shape.py b/services/calc/debrief_calc/tools/shape/manipulation/enlarge_shape.py new file mode 100644 index 00000000..208502a3 --- /dev/null +++ b/services/calc/debrief_calc/tools/shape/manipulation/enlarge_shape.py @@ -0,0 +1,193 @@ +"""Enlarge Shape tool — scale annotation features by a multiplicative factor.""" + +from __future__ import annotations + +from typing import Any + +from debrief_calc.models import ContextType, SelectionContext, ToolParameter +from debrief_calc.registry import tool + +ANNOTATION_KINDS = {"CIRCLE", "RECTANGLE", "LINE", "TEXT", "VECTOR"} + + +def compute_centroid(geometry: dict[str, Any]) -> list[float]: + """ + Compute arithmetic mean of vertices as the geometric centroid. + + For Polygon: uses exterior ring, excluding closing vertex. + For LineString: uses all coordinates. + For Point: returns the coordinate itself. + + Args: + geometry: GeoJSON geometry dict with 'type' and 'coordinates'. + + Returns: + [longitude, latitude] centroid coordinate. + """ + geo_type = geometry.get("type") + coords = geometry.get("coordinates") + + if geo_type == "Point": + return list(coords) + + if geo_type == "Polygon": + ring = coords[0] + # Exclude closing vertex if first == last + if len(ring) > 1 and ring[0] == ring[-1]: + vertices = ring[:-1] + else: + vertices = ring + elif geo_type == "LineString": + vertices = coords + else: + vertices = coords + + n = len(vertices) + if n == 0: + return [0.0, 0.0] + + sum_lon = sum(v[0] for v in vertices) + sum_lat = sum(v[1] for v in vertices) + return [sum_lon / n, sum_lat / n] + + +def scale_coordinate( + coord: list[float], origin: list[float], scale_factor: float +) -> list[float]: + """ + Scale a single [lon, lat] coordinate relative to an origin. + + Args: + coord: [longitude, latitude] to scale. + origin: [longitude, latitude] scaling origin. + scale_factor: Multiplicative scaling factor. + + Returns: + Scaled [longitude, latitude]. + """ + new_lon = origin[0] + (coord[0] - origin[0]) * scale_factor + new_lat = origin[1] + (coord[1] - origin[1]) * scale_factor + + # Clamp latitude to [-90, 90] + new_lat = max(-90.0, min(90.0, new_lat)) + + # Normalise longitude to [-180, 180] + new_lon = ((new_lon + 180) % 360) - 180 + + return [new_lon, new_lat] + + +def _scale_coords_list( + coords: list[list[float]], origin: list[float], scale_factor: float +) -> list[list[float]]: + """Scale a list of [lon, lat] coordinates relative to an origin.""" + return [scale_coordinate(c, origin, scale_factor) for c in coords] + + +@tool( + name="enlarge-shape", + description=( + "Scales annotation shapes by a multiplicative factor relative to an origin point. " + "Uses linear interpolation of geographic coordinate differences. " + "Supports CIRCLE, RECTANGLE, LINE, TEXT, and VECTOR annotations." + ), + input_kinds=["CIRCLE", "RECTANGLE", "LINE", "TEXT", "VECTOR"], + output_kind="mutation/shape/scaled", + context_type=ContextType.MULTI, + parameters=[ + ToolParameter( + name="scale_factor", + type="number", + description="Multiplicative scaling factor (>1 enlarges, <1 shrinks, 1 is no-op)", + required=False, + default=3.0, + choices=[0.25, 0.5, 1.5, 2.0, 3.0, 5.0], + ), + ToolParameter( + name="origin", + type="string", + description=( + "Scaling origin as [longitude, latitude] JSON array. " + "Default: geometric centroid of each shape." + ), + required=False, + default=None, + ), + ], +) +def enlarge_shape(context: SelectionContext, params: dict[str, Any]) -> list[dict[str, Any]]: + """ + Scale annotation shapes by a multiplicative factor relative to an origin. + + Args: + context: SelectionContext with one or more annotation features. + params: Parameters dict with 'scale_factor' (default 3.0) + and optional 'origin' ([lon, lat] or None for centroid). + + Returns: + List of modified features with scaled coordinates. + """ + scale_factor = float(params.get("scale_factor", 3.0)) + + # Parse origin — may be a list or a JSON string + raw_origin = params.get("origin") + if isinstance(raw_origin, str): + import json + origin = json.loads(raw_origin) + elif isinstance(raw_origin, list): + origin = raw_origin + else: + origin = None + + if scale_factor < 0: + raise ValueError("scale_factor must be >= 0") + + modified = [] + for feature in context.features: + props = feature.get("properties", {}) + kind = props.get("kind") + + if kind not in ANNOTATION_KINDS: + continue + + geometry = feature.get("geometry", {}) + + # Determine scaling origin + scaling_origin = origin if origin is not None else compute_centroid(geometry) + + # Scale factor of 1.0 is a no-op — return feature unchanged + if scale_factor == 1.0: + modified.append(feature) + continue + + coords = geometry.get("coordinates") + + if kind in ("CIRCLE", "RECTANGLE"): + # Polygon geometry: scale all rings + geometry["coordinates"] = [ + _scale_coords_list(ring, scaling_origin, scale_factor) for ring in coords + ] + if kind == "CIRCLE" and "center" in props: + props["center"] = scale_coordinate(props["center"], scaling_origin, scale_factor) + + elif kind == "LINE": + # LineString geometry: scale all coordinates + geometry["coordinates"] = _scale_coords_list(coords, scaling_origin, scale_factor) + + elif kind == "TEXT": + # Point geometry: scale single coordinate + geometry["coordinates"] = scale_coordinate(coords, scaling_origin, scale_factor) + + elif kind == "VECTOR": + # LineString geometry: scale all coordinates + geometry["coordinates"] = _scale_coords_list(coords, scaling_origin, scale_factor) + # Update origin property; preserve range and bearing + if "origin" in props: + props["origin"] = scale_coordinate(props["origin"], scaling_origin, scale_factor) + + modified.append(feature) + + if not modified: + raise ValueError("No annotation features found in input") + + return modified diff --git a/services/calc/tests/tools/shape/manipulation/test_enlarge_shape.py b/services/calc/tests/tools/shape/manipulation/test_enlarge_shape.py new file mode 100644 index 00000000..f338eaf7 --- /dev/null +++ b/services/calc/tests/tools/shape/manipulation/test_enlarge_shape.py @@ -0,0 +1,440 @@ +"""Golden example tests for enlarge-shape tool (057).""" + +import copy +import json +from pathlib import Path + +import pytest +from debrief_calc.models import ContextType, SelectionContext +from debrief_calc.tools.shape.manipulation.enlarge_shape import ( + compute_centroid, + enlarge_shape, + scale_coordinate, +) + +GOLDEN_DIR = Path(__file__).parents[6] / "shared" / "tools" / "shape" / "manipulation" + +# Test fixtures + +RECTANGLE_FEATURE = { + "type": "Feature", + "id": "rect-001", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-1.0, 51.0], + [-0.5, 51.0], + [-0.5, 51.5], + [-1.0, 51.5], + [-1.0, 51.0], + ] + ], + }, + "properties": { + "kind": "RECTANGLE", + "label": "Exercise Area", + "style": { + "fill": True, + "fill_color": "#3388FF", + "fill_opacity": 0.2, + "stroke": True, + "color": "#3388FF", + "weight": 2, + "opacity": 1.0, + }, + }, +} + +CIRCLE_FEATURE = { + "type": "Feature", + "id": "circle-002", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [0.0, 50.009], + [0.01, 50.006], + [0.014, 50.0], + [0.01, 49.994], + [0.0, 49.991], + [-0.01, 49.994], + [-0.014, 50.0], + [-0.01, 50.006], + [0.0, 50.009], + ] + ], + }, + "properties": { + "kind": "CIRCLE", + "center": [0.0, 50.0], + "radius": 1000, + "label": "Search Area", + }, +} + +VECTOR_FEATURE = { + "type": "Feature", + "id": "vector-001", + "geometry": { + "type": "LineString", + "coordinates": [ + [0.0, 50.0], + [0.1, 50.1], + ], + }, + "properties": { + "kind": "VECTOR", + "origin": [0.0, 50.0], + "range": 12000, + "bearing": 45, + "label": "Search Vector", + }, +} + +LINE_FEATURE = { + "type": "Feature", + "id": "line-001", + "geometry": { + "type": "LineString", + "coordinates": [ + [-1.0, 50.0], + [-1.1, 50.1], + ], + }, + "properties": { + "kind": "LINE", + "label": "Nav Line", + }, +} + +TEXT_FEATURE = { + "type": "Feature", + "id": "text-001", + "geometry": { + "type": "Point", + "coordinates": [0.0, 50.0], + }, + "properties": { + "kind": "TEXT", + "text": "Waypoint Alpha", + }, +} + + +class TestComputeCentroid: + """Tests for centroid computation.""" + + def test_polygon_centroid(self): + """Compute centroid of a rectangle. Verify arithmetic mean of 4 unique vertices.""" + geometry = { + "type": "Polygon", + "coordinates": [[[-1.0, 51.0], [-0.5, 51.0], [-0.5, 51.5], [-1.0, 51.5], [-1.0, 51.0]]], + } + centroid = compute_centroid(geometry) + assert centroid[0] == pytest.approx(-0.75) + assert centroid[1] == pytest.approx(51.25) + + def test_linestring_centroid(self): + """Compute centroid of a line. Verify midpoint.""" + geometry = {"type": "LineString", "coordinates": [[0.0, 50.0], [0.1, 50.1]]} + centroid = compute_centroid(geometry) + assert centroid[0] == pytest.approx(0.05) + assert centroid[1] == pytest.approx(50.05) + + def test_point_centroid(self): + """Compute centroid of a point. Should return the point itself.""" + geometry = {"type": "Point", "coordinates": [1.0, 2.0]} + centroid = compute_centroid(geometry) + assert centroid == [1.0, 2.0] + + +class TestScaleCoordinate: + """Tests for the scale_coordinate utility function.""" + + def test_scale_from_origin(self): + """Scale coordinate by factor 3 from origin [-0.75, 51.25].""" + result = scale_coordinate([-1.0, 51.0], [-0.75, 51.25], 3.0) + assert result[0] == pytest.approx(-1.5) + assert result[1] == pytest.approx(50.5) + + def test_identity_scale(self): + """Scale by factor 1. Verify coordinate unchanged.""" + result = scale_coordinate([1.0, 2.0], [0.0, 0.0], 1.0) + assert result[0] == pytest.approx(1.0) + assert result[1] == pytest.approx(2.0) + + def test_zero_scale(self): + """Scale by factor 0. Verify coordinate collapses to origin.""" + result = scale_coordinate([5.0, 10.0], [1.0, 2.0], 0.0) + assert result[0] == pytest.approx(1.0) + assert result[1] == pytest.approx(2.0) + + def test_latitude_clamping(self): + """Scale near pole. Verify latitude clamped to 90.""" + result = scale_coordinate([0.0, 89.0], [0.0, 80.0], 5.0) + assert result[1] == 90.0 # Clamped + + +class TestEnlargeShapeGoldenBasicPolygon: + """Golden example tests: basic-polygon (US1, scale 3.0 from centroid).""" + + @pytest.fixture + def golden_input(self): + with open(GOLDEN_DIR / "enlarge-shape.basic-polygon.input.json") as f: + return json.load(f) + + @pytest.fixture + def golden_output(self): + with open(GOLDEN_DIR / "enlarge-shape.basic-polygon.output.json") as f: + return json.load(f) + + def test_basic_polygon_matches_golden(self, golden_input, golden_output): + """Scale rectangle 3x from centroid. Verify output matches golden example.""" + features = golden_input["features"] + params = golden_input["parameters"] + context = SelectionContext(type=ContextType.SINGLE, features=copy.deepcopy(features)) + + result = enlarge_shape(context, params) + + assert len(result) == 1 + expected_feature = json.loads(golden_output["content"][0]["text"]) + expected_coords = expected_feature["geometry"]["coordinates"][0] + result_coords = result[0]["geometry"]["coordinates"][0] + + for exp_pt, res_pt in zip(expected_coords, result_coords, strict=True): + assert res_pt[0] == pytest.approx(exp_pt[0], abs=1e-9) + assert res_pt[1] == pytest.approx(exp_pt[1], abs=1e-9) + + def test_basic_polygon_centroid_preserved(self, golden_input): + """Scale 3x from centroid. Verify centroid of output matches centroid of input.""" + features = golden_input["features"] + params = golden_input["parameters"] + context = SelectionContext(type=ContextType.SINGLE, features=copy.deepcopy(features)) + + original_centroid = compute_centroid(features[0]["geometry"]) + result = enlarge_shape(context, params) + result_centroid = compute_centroid(result[0]["geometry"]) + + assert result_centroid[0] == pytest.approx(original_centroid[0], abs=1e-9) + assert result_centroid[1] == pytest.approx(original_centroid[1], abs=1e-9) + + +class TestEnlargeShapeGoldenCustomOrigin: + """Golden example tests: custom-origin (US2, scale 2.0 from vertex).""" + + @pytest.fixture + def golden_input(self): + with open(GOLDEN_DIR / "enlarge-shape.custom-origin.input.json") as f: + return json.load(f) + + @pytest.fixture + def golden_output(self): + with open(GOLDEN_DIR / "enlarge-shape.custom-origin.output.json") as f: + return json.load(f) + + def test_custom_origin_matches_golden(self, golden_input, golden_output): + """Scale rectangle 2x from vertex. Verify output matches golden example.""" + features = golden_input["features"] + params = golden_input["parameters"] + context = SelectionContext(type=ContextType.SINGLE, features=copy.deepcopy(features)) + + result = enlarge_shape(context, params) + + assert len(result) == 1 + expected_feature = json.loads(golden_output["content"][0]["text"]) + expected_coords = expected_feature["geometry"]["coordinates"][0] + result_coords = result[0]["geometry"]["coordinates"][0] + + for exp_pt, res_pt in zip(expected_coords, result_coords, strict=True): + assert res_pt[0] == pytest.approx(exp_pt[0], abs=1e-9) + assert res_pt[1] == pytest.approx(exp_pt[1], abs=1e-9) + + def test_origin_vertex_fixed(self, golden_input): + """Scale 2x from vertex [-1.0, 51.0]. Verify that vertex is unchanged.""" + features = golden_input["features"] + params = golden_input["parameters"] + context = SelectionContext(type=ContextType.SINGLE, features=copy.deepcopy(features)) + + result = enlarge_shape(context, params) + + origin = params["origin"] + result_coords = result[0]["geometry"]["coordinates"][0] + # First vertex should be the origin, unchanged + assert result_coords[0][0] == pytest.approx(origin[0], abs=1e-9) + assert result_coords[0][1] == pytest.approx(origin[1], abs=1e-9) + + +class TestEnlargeShapeGoldenNoop: + """Golden example tests: noop (US3, scale 1.0 identity).""" + + @pytest.fixture + def golden_input(self): + with open(GOLDEN_DIR / "enlarge-shape.noop.input.json") as f: + return json.load(f) + + @pytest.fixture + def golden_output(self): + with open(GOLDEN_DIR / "enlarge-shape.noop.output.json") as f: + return json.load(f) + + def test_noop_matches_golden(self, golden_input, golden_output): + """Scale circle 1.0x (noop). Verify output matches golden example.""" + features = golden_input["features"] + params = golden_input["parameters"] + context = SelectionContext(type=ContextType.SINGLE, features=copy.deepcopy(features)) + + result = enlarge_shape(context, params) + + assert len(result) == 1 + expected_feature = json.loads(golden_output["content"][0]["text"]) + expected_coords = expected_feature["geometry"]["coordinates"][0] + result_coords = result[0]["geometry"]["coordinates"][0] + + # Coordinates should be exactly unchanged + for exp_pt, res_pt in zip(expected_coords, result_coords, strict=True): + assert res_pt[0] == exp_pt[0] + assert res_pt[1] == exp_pt[1] + + def test_noop_center_preserved(self, golden_input): + """Scale circle 1.0x. Verify center property unchanged.""" + features = golden_input["features"] + params = golden_input["parameters"] + original_center = list(features[0]["properties"]["center"]) + context = SelectionContext(type=ContextType.SINGLE, features=copy.deepcopy(features)) + + result = enlarge_shape(context, params) + + assert result[0]["properties"]["center"] == original_center + + +class TestEnlargeShapePerKind: + """Tests for per-kind annotation handling.""" + + def test_circle_center_scaled(self): + """Scale circle. Verify center property is scaled.""" + feature = copy.deepcopy(CIRCLE_FEATURE) + context = SelectionContext(type=ContextType.SINGLE, features=[feature]) + params = {"scale_factor": 2.0} + + result = enlarge_shape(context, params) + + assert len(result) == 1 + # Center should have moved away from centroid + original_center = CIRCLE_FEATURE["properties"]["center"] + result_center = result[0]["properties"]["center"] + # For a symmetric circle centered at centroid, center stays the same + assert result_center[0] == pytest.approx(original_center[0], abs=0.001) + assert result_center[1] == pytest.approx(original_center[1], abs=0.001) + + def test_vector_origin_scaled(self): + """Scale vector. Verify origin property scaled, range and bearing preserved.""" + feature = copy.deepcopy(VECTOR_FEATURE) + context = SelectionContext(type=ContextType.SINGLE, features=[feature]) + params = {"scale_factor": 2.0} + + result = enlarge_shape(context, params) + + assert len(result) == 1 + # Range and bearing preserved + assert result[0]["properties"]["range"] == VECTOR_FEATURE["properties"]["range"] + assert result[0]["properties"]["bearing"] == VECTOR_FEATURE["properties"]["bearing"] + + def test_line_coords_scaled(self): + """Scale line by factor 3. Verify coordinates change.""" + feature = copy.deepcopy(LINE_FEATURE) + context = SelectionContext(type=ContextType.SINGLE, features=[feature]) + params = {"scale_factor": 3.0} + + result = enlarge_shape(context, params) + + assert len(result) == 1 + orig_coords = LINE_FEATURE["geometry"]["coordinates"] + result_coords = result[0]["geometry"]["coordinates"] + # Verify coordinates have changed + assert result_coords[0] != orig_coords[0] or result_coords[1] != orig_coords[1] + + def test_text_point_scaled(self): + """Scale text point by factor 2 from explicit origin. Verify position changes.""" + feature = copy.deepcopy(TEXT_FEATURE) + context = SelectionContext(type=ContextType.SINGLE, features=[feature]) + params = {"scale_factor": 2.0, "origin": [-1.0, 49.0]} + + result = enlarge_shape(context, params) + + assert len(result) == 1 + result_coords = result[0]["geometry"]["coordinates"] + # new_lon = -1.0 + (0.0 - -1.0) * 2.0 = -1.0 + 2.0 = 1.0 + # new_lat = 49.0 + (50.0 - 49.0) * 2.0 = 49.0 + 2.0 = 51.0 + assert result_coords[0] == pytest.approx(1.0, abs=1e-9) + assert result_coords[1] == pytest.approx(51.0, abs=1e-9) + + +class TestEnlargeShapeEdgeCases: + """Edge case tests for enlarge-shape tool.""" + + def test_zero_scale_collapses_to_origin(self): + """Scale by factor 0. All vertices collapse to centroid.""" + feature = copy.deepcopy(RECTANGLE_FEATURE) + context = SelectionContext(type=ContextType.SINGLE, features=[feature]) + params = {"scale_factor": 0.0} + + result = enlarge_shape(context, params) + + assert len(result) == 1 + result_coords = result[0]["geometry"]["coordinates"][0] + # All vertices should be at the centroid [-0.75, 51.25] + for pt in result_coords: + assert pt[0] == pytest.approx(-0.75, abs=1e-9) + assert pt[1] == pytest.approx(51.25, abs=1e-9) + + def test_negative_scale_error(self): + """Scale with negative factor. Verify ValueError raised.""" + feature = copy.deepcopy(RECTANGLE_FEATURE) + context = SelectionContext(type=ContextType.SINGLE, features=[feature]) + params = {"scale_factor": -1.0} + + with pytest.raises(ValueError, match="scale_factor must be >= 0"): + enlarge_shape(context, params) + + def test_empty_features_error(self): + """Scale with empty feature list. Verify ValueError raised.""" + context = SelectionContext(type=ContextType.NONE, features=[]) + params = {"scale_factor": 2.0} + + with pytest.raises(ValueError, match="No annotation features found"): + enlarge_shape(context, params) + + def test_non_annotation_skipped(self): + """Scale with TRACK feature and annotation. Verify only annotation returned.""" + track = { + "type": "Feature", + "id": "track-001", + "geometry": {"type": "LineString", "coordinates": [[-1.0, 50.0], [-1.1, 50.1]]}, + "properties": {"kind": "TRACK"}, + } + annotation = copy.deepcopy(TEXT_FEATURE) + context = SelectionContext(type=ContextType.MULTI, features=[track, annotation]) + params = {"scale_factor": 2.0} + + result = enlarge_shape(context, params) + + assert len(result) == 1 + assert result[0]["id"] == "text-001" + + def test_default_params(self): + """Scale without explicit params. Verify defaults used (scale_factor=3.0).""" + feature = copy.deepcopy(RECTANGLE_FEATURE) + context = SelectionContext(type=ContextType.SINGLE, features=[feature]) + params = {} + + result = enlarge_shape(context, params) + + assert len(result) == 1 + # With default scale_factor=3.0, the shape should be larger + orig_coords = RECTANGLE_FEATURE["geometry"]["coordinates"][0] + result_coords = result[0]["geometry"]["coordinates"][0] + # Original extent: 0.5° lon x 0.5° lat + orig_extent_lon = max(c[0] for c in orig_coords) - min(c[0] for c in orig_coords) + result_extent_lon = max(c[0] for c in result_coords) - min(c[0] for c in result_coords) + assert result_extent_lon == pytest.approx(orig_extent_lon * 3.0, abs=1e-9) diff --git a/shared/tools/shape/manipulation/enlarge-shape.1.0.md b/shared/tools/shape/manipulation/enlarge-shape.1.0.md new file mode 100644 index 00000000..1d03a89a --- /dev/null +++ b/shared/tools/shape/manipulation/enlarge-shape.1.0.md @@ -0,0 +1,357 @@ +--- +name: enlarge-shape +version: 1.0 +category: shape/manipulation +status: draft +--- + +# Enlarge Shape + +> Scales annotation shapes by a multiplicative factor relative to an origin point (default: geometric centroid). + +## MCP + +**Description**: Scales annotation shapes (circles, rectangles, lines, vectors, text) by a multiplicative factor relative to a specified origin point. Uses linear interpolation of geographic coordinate differences. + +**When to use**: When an analyst needs to enlarge, shrink, or proportionally resize one or more shape annotations relative to their geometric center or a custom anchor point. + +**Parameters**: +- `scale_factor`: Multiplicative scaling factor. Default: 3.0. Must be >= 0. Preset choices: [0.25, 0.5, 1.5, 2.0, 3.0, 5.0]. +- `origin`: Scaling origin as [longitude, latitude]. Default: geometric centroid of the shape. + +**Returns**: Mutation ToolResponse with scaled annotation features. + +## Inputs + +**Schema**: `shared/schemas/src/linkml/annotations.yaml#{CircleAnnotation, RectangleAnnotation, LineAnnotation, TextAnnotation, VectorAnnotation}` + +**Constraints**: +- FeatureCollection must contain at least one annotation feature +- Non-annotation features are silently skipped during processing +- Scale factor must be non-negative; negative values return an error +- Scale factor of 0 collapses all vertices to the origin point (degenerate geometry) +- Scale factor of 1.0 returns features unchanged (identity/no-op) +- Origin, if provided, must be a valid [longitude, latitude] coordinate + +**Defaults**: +- `scale_factor`: 3.0 +- `origin`: Geometric centroid (arithmetic mean of vertices, excluding closing vertex for polygons) + +**Parameter Presets**: +- `scale_factor` is declared as `type="number"` with `choices=[0.25, 0.5, 1.5, 2.0, 3.0, 5.0]` +- Presets enable frontend context menus for quick selection +- Any non-negative numeric value is accepted via custom input — presets are convenience, not constraints + +## Outputs + +Tools return a **ToolResponse** containing one or more content items with Debrief annotations. + +**Response Schema**: `specs/041-document-tool-results/data-model.md#ToolResponse` + +### Result Type Path + +**Format**: `{top_type}/{domain}/{specific_type}` + +The `result_subtype` used in builder functions is `shape/scaled`. + +### Annotations + +Required on each content item: +- `debrief:resultType`: `mutation/shape/scaled` +- `debrief:sourceFeatures`: Array of input feature IDs that were scaled +- `debrief:label`: Human-readable description in format "Scaled {n} shape(s) by factor {scale_factor} from {origin_description}" + +## Algorithm + +```pseudocode +FUNCTION compute_centroid(geometry) -> [lon, lat]: + // Compute arithmetic mean of vertices + IF geometry.type == "Polygon": + ring = geometry.coordinates[0] // Exterior ring + // Exclude closing vertex (last == first for closed polygons) + IF ring[0] == ring[length(ring) - 1]: + vertices = ring[0 .. length(ring) - 2] + ELSE: + vertices = ring + END IF + ELSE IF geometry.type == "LineString": + vertices = geometry.coordinates + ELSE IF geometry.type == "Point": + RETURN geometry.coordinates // Single point IS the centroid + END IF + + sum_lon = 0 + sum_lat = 0 + FOR EACH vertex IN vertices: + sum_lon = sum_lon + vertex[0] + sum_lat = sum_lat + vertex[1] + END FOR + + RETURN [sum_lon / length(vertices), sum_lat / length(vertices)] +END FUNCTION + +FUNCTION scale_coordinate(coord, origin, scale_factor) -> [lon, lat]: + new_lon = origin[0] + (coord[0] - origin[0]) * scale_factor + new_lat = origin[1] + (coord[1] - origin[1]) * scale_factor + + // Clamp latitude to [-90, 90] + new_lat = max(-90, min(90, new_lat)) + + // Normalise longitude to [-180, 180] + new_lon = ((new_lon + 180) mod 360) - 180 + + RETURN [new_lon, new_lat] +END FUNCTION + +FUNCTION enlarge_shape(input: FeatureCollection, scale_factor: number, origin: [lon, lat] | null) -> ToolResponse: + IF input IS NULL OR input.features IS EMPTY: + RETURN build_error("Input features required", "invalid_input", []) + END IF + + // Validate scale factor + IF scale_factor < 0: + RETURN build_error("Scale factor must be non-negative", "invalid_input", []) + END IF + + modified_features = empty list + source_ids = empty list + + FOR EACH feature IN input.features: + kind = feature.properties.kind + + IF kind NOT IN {CIRCLE, RECTANGLE, LINE, TEXT, VECTOR}: + CONTINUE // Skip non-annotation features silently + END IF + + source_ids.append(feature.id) + + // Determine scaling origin + IF origin IS NOT NULL: + scaling_origin = origin + ELSE: + scaling_origin = compute_centroid(feature.geometry) + END IF + + // Scale factor of 1.0 is a no-op — return feature unchanged + IF scale_factor == 1.0: + modified_features.append(feature) + CONTINUE + END IF + + IF kind == "CIRCLE": + // Polygon geometry: scale all vertices in all rings + FOR EACH ring IN feature.geometry.coordinates: + FOR i = 0 TO length(ring) - 1: + ring[i] = scale_coordinate(ring[i], scaling_origin, scale_factor) + END FOR + END FOR + // Update center property if present + IF feature.properties.center IS NOT NULL: + feature.properties.center = scale_coordinate( + feature.properties.center, scaling_origin, scale_factor + ) + END IF + + ELSE IF kind == "RECTANGLE": + // Polygon geometry: scale all vertices in all rings + FOR EACH ring IN feature.geometry.coordinates: + FOR i = 0 TO length(ring) - 1: + ring[i] = scale_coordinate(ring[i], scaling_origin, scale_factor) + END FOR + END FOR + + ELSE IF kind == "LINE": + // LineString geometry: scale all coordinates + FOR i = 0 TO length(feature.geometry.coordinates) - 1: + feature.geometry.coordinates[i] = scale_coordinate( + feature.geometry.coordinates[i], scaling_origin, scale_factor + ) + END FOR + + ELSE IF kind == "TEXT": + // Point geometry: scale single coordinate + feature.geometry.coordinates = scale_coordinate( + feature.geometry.coordinates, scaling_origin, scale_factor + ) + + ELSE IF kind == "VECTOR": + // LineString geometry: scale all coordinates + FOR i = 0 TO length(feature.geometry.coordinates) - 1: + feature.geometry.coordinates[i] = scale_coordinate( + feature.geometry.coordinates[i], scaling_origin, scale_factor + ) + END FOR + // Update origin property; preserve range and bearing + IF feature.properties.origin IS NOT NULL: + feature.properties.origin = scale_coordinate( + feature.properties.origin, scaling_origin, scale_factor + ) + END IF + END IF + + modified_features.append(feature) + END FOR + + IF modified_features IS EMPTY: + RETURN build_error("No annotation features found", "invalid_input", []) + END IF + + // Determine origin description for label + IF origin IS NOT NULL: + origin_desc = "[{origin[0]}, {origin[1]}]" + ELSE: + origin_desc = "centroid" + END IF + + // Build response with mutation result type + content_items = build_mutation( + features: modified_features, + result_subtype: "shape/scaled", + source_feature_ids: source_ids, + label: "Scaled {count} shape(s) by factor {scale_factor} from {origin_desc}" + ) + + RETURN build_response(content_items) +END FUNCTION +``` + +### Response Builder Functions + +| Function | Result Type | Use When | +|----------|-------------|----------| +| `build_mutation(features, subtype, sources, label)` | `mutation/*` | Modifying existing features | +| `build_error(message, category, affected_ids)` | Error | Reporting failures | + +## Edge Cases + +| Scenario | Expected Behavior | +|----------|------------------| +| Empty feature collection | Return error with `invalid_input` category and message "Input features required" | +| Scale factor of 1.0 (no-op) | Return annotation features unchanged; include in result with provenance recording factor 1.0 | +| Scale factor of 0 | Collapse all vertices to the origin point; return degenerate geometry with provenance | +| Negative scale factor | Return error with `invalid_input` category and message "Scale factor must be non-negative" | +| Very large scale factor (e.g., 1000) near poles | Clamp output latitude to [-90, 90]; normalise longitude to [-180, 180] | +| Non-annotation features | Skip silently; process only CIRCLE, RECTANGLE, LINE, TEXT, VECTOR kinds | +| No annotation features after filtering | Return error with `invalid_input` category and message "No annotation features found" | +| Polygon with multiple rings (holes) | Scale all rings (exterior and interior) relative to the same origin | +| CIRCLE annotation with `center` property | Update center by scaling it relative to the origin (same formula as vertices) | +| VECTOR annotation with `origin` property | Update origin by scaling it relative to the scaling origin; preserve `range` and `bearing` | +| TEXT annotation (Point geometry) | Scale the single coordinate relative to the origin | +| Closing vertex in polygon ring | Scale the closing vertex identically to the first vertex (maintains ring closure) | +| Custom origin at a vertex of the shape | That vertex remains fixed; all others scale outward (or inward) from it | +| Custom origin outside the shape | All vertices shift relative to the external origin point | +| Antimeridian crossing (lon > 180 or < -180) | Normalise longitude to [-180, 180] using formula: `((lon + 180) mod 360) - 180` | + +## Examples + +### Basic Example: Scaling a Rectangle by Factor 3.0 from Centroid + +**Input** (FeatureCollection with a single rectangle): +```json +{ + "type": "FeatureCollection", + "features": [ + { + "id": "rect-001", + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-1.0, 51.0], + [-0.5, 51.0], + [-0.5, 51.5], + [-1.0, 51.5], + [-1.0, 51.0] + ]] + }, + "properties": { + "kind": "RECTANGLE", + "label": "Exercise Area" + } + } + ], + "parameters": { + "scale_factor": 3.0 + } +} +``` + +**Centroid computation** (arithmetic mean of 4 unique vertices): +- lon: (-1.0 + -0.5 + -0.5 + -1.0) / 4 = -0.75 +- lat: (51.0 + 51.0 + 51.5 + 51.5) / 4 = 51.25 + +**Scaling** (each vertex: `origin + (vertex - origin) * 3.0`): +- [-1.0, 51.0] → [-0.75 + (-0.25)*3, 51.25 + (-0.25)*3] = [-1.5, 50.5] +- [-0.5, 51.0] → [-0.75 + (0.25)*3, 51.25 + (-0.25)*3] = [0.0, 50.5] +- [-0.5, 51.5] → [0.0, 52.0] +- [-1.0, 51.5] → [-1.5, 52.0] +- [-1.0, 51.0] → [-1.5, 50.5] (closing vertex) + +**Output** (ToolResponse): +```json +{ + "content": [ + { + "type": "resource", + "uri": "feature://rect-001", + "mimeType": "application/geo+json", + "text": "{\"id\":\"rect-001\",\"type\":\"Feature\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[-1.5,50.5],[0.0,50.5],[0.0,52.0],[-1.5,52.0],[-1.5,50.5]]]},\"properties\":{\"kind\":\"RECTANGLE\",\"label\":\"Exercise Area\"}}", + "annotations": { + "debrief:resultType": "mutation/shape/scaled", + "debrief:sourceFeatures": ["rect-001"], + "debrief:label": "Scaled 1 shape(s) by factor 3.0 from centroid" + } + } + ] +} +``` + +### Golden Example Files + +For testable examples, create sister files in the same directory: + +| Example | Input File | Output File | Validates | +|---------|-----------|-------------|-----------| +| basic-polygon | `enlarge-shape.basic-polygon.input.json` | `enlarge-shape.basic-polygon.output.json` | Core scaling from centroid (factor 3.0) | +| custom-origin | `enlarge-shape.custom-origin.input.json` | `enlarge-shape.custom-origin.output.json` | Custom origin support (factor 2.0, origin at vertex) | +| noop | `enlarge-shape.noop.input.json` | `enlarge-shape.noop.output.json` | Identity transformation (factor 1.0) | + +### Error Response Example + +```json +{ + "error": { + "code": -32000, + "message": "Scale factor must be non-negative", + "data": { + "debrief:errorCategory": "invalid_input", + "debrief:affectedFeatures": [] + } + } +} +``` + +## Changelog + +### 1.0 (2026-02-13) +- Initial release +- Supports CIRCLE, RECTANGLE, LINE, TEXT, VECTOR annotation kinds +- Uses linear interpolation of geographic coordinate differences for scaling +- Supports custom origin point or automatic centroid computation +- Handles latitude clamping and longitude normalisation for extreme scaling +- Declares `scale_factor` with preset choices [0.25, 0.5, 1.5, 2.0, 3.0, 5.0] for frontend context menus + +## References + +**Related Tools**: +- [move-shape](./move-shape.1.0.md) - Sibling shape manipulation tool; translates shapes by bearing and distance + +**Schemas**: +- [annotations.yaml](../../schemas/src/linkml/annotations.yaml) - Annotation feature definitions (CircleAnnotation, RectangleAnnotation, LineAnnotation, TextAnnotation, VectorAnnotation) +- [common.yaml](../../schemas/src/linkml/common.yaml) - FeatureKindEnum and shared types + +**Template**: +- [TEMPLATE.md](../TEMPLATE.md) - Tool specification template + +**External**: +- [Geographic coordinate system](https://en.wikipedia.org/wiki/Geographic_coordinate_system) — latitude/longitude reference system used for coordinate scaling diff --git a/shared/tools/shape/manipulation/enlarge-shape.basic-polygon.input.json b/shared/tools/shape/manipulation/enlarge-shape.basic-polygon.input.json new file mode 100644 index 00000000..4ec396bd --- /dev/null +++ b/shared/tools/shape/manipulation/enlarge-shape.basic-polygon.input.json @@ -0,0 +1,35 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "rect-001", + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-1.0, 51.0], + [-0.5, 51.0], + [-0.5, 51.5], + [-1.0, 51.5], + [-1.0, 51.0] + ]] + }, + "properties": { + "kind": "RECTANGLE", + "label": "Exercise Area", + "style": { + "fill": true, + "fill_color": "#3388FF", + "fill_opacity": 0.2, + "stroke": true, + "color": "#3388FF", + "weight": 2, + "opacity": 1.0 + } + } + } + ], + "parameters": { + "scale_factor": 3.0 + } +} diff --git a/shared/tools/shape/manipulation/enlarge-shape.basic-polygon.output.json b/shared/tools/shape/manipulation/enlarge-shape.basic-polygon.output.json new file mode 100644 index 00000000..19f97563 --- /dev/null +++ b/shared/tools/shape/manipulation/enlarge-shape.basic-polygon.output.json @@ -0,0 +1,15 @@ +{ + "content": [ + { + "type": "resource", + "uri": "feature://rect-001", + "mimeType": "application/geo+json", + "text": "{\"type\":\"Feature\",\"id\":\"rect-001\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[-1.5,50.5],[0.0,50.5],[0.0,52.0],[-1.5,52.0],[-1.5,50.5]]]},\"properties\":{\"kind\":\"RECTANGLE\",\"label\":\"Exercise Area\",\"style\":{\"fill\":true,\"fill_color\":\"#3388FF\",\"fill_opacity\":0.2,\"stroke\":true,\"color\":\"#3388FF\",\"weight\":2,\"opacity\":1.0}}}", + "annotations": { + "debrief:resultType": "mutation/shape/scaled", + "debrief:sourceFeatures": ["rect-001"], + "debrief:label": "Scaled 1 shape(s) by factor 3.0 from centroid" + } + } + ] +} diff --git a/shared/tools/shape/manipulation/enlarge-shape.custom-origin.input.json b/shared/tools/shape/manipulation/enlarge-shape.custom-origin.input.json new file mode 100644 index 00000000..0403a46a --- /dev/null +++ b/shared/tools/shape/manipulation/enlarge-shape.custom-origin.input.json @@ -0,0 +1,36 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "rect-002", + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-1.0, 51.0], + [-0.5, 51.0], + [-0.5, 51.5], + [-1.0, 51.5], + [-1.0, 51.0] + ]] + }, + "properties": { + "kind": "RECTANGLE", + "label": "Patrol Zone", + "style": { + "fill": true, + "fill_color": "#FF5733", + "fill_opacity": 0.3, + "stroke": true, + "color": "#FF0000", + "weight": 2, + "opacity": 1.0 + } + } + } + ], + "parameters": { + "scale_factor": 2.0, + "origin": [-1.0, 51.0] + } +} diff --git a/shared/tools/shape/manipulation/enlarge-shape.custom-origin.output.json b/shared/tools/shape/manipulation/enlarge-shape.custom-origin.output.json new file mode 100644 index 00000000..ec80ce34 --- /dev/null +++ b/shared/tools/shape/manipulation/enlarge-shape.custom-origin.output.json @@ -0,0 +1,15 @@ +{ + "content": [ + { + "type": "resource", + "uri": "feature://rect-002", + "mimeType": "application/geo+json", + "text": "{\"type\":\"Feature\",\"id\":\"rect-002\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[-1.0,51.0],[0.0,51.0],[0.0,52.0],[-1.0,52.0],[-1.0,51.0]]]},\"properties\":{\"kind\":\"RECTANGLE\",\"label\":\"Patrol Zone\",\"style\":{\"fill\":true,\"fill_color\":\"#FF5733\",\"fill_opacity\":0.3,\"stroke\":true,\"color\":\"#FF0000\",\"weight\":2,\"opacity\":1.0}}}", + "annotations": { + "debrief:resultType": "mutation/shape/scaled", + "debrief:sourceFeatures": ["rect-002"], + "debrief:label": "Scaled 1 shape(s) by factor 2.0 from [-1.0, 51.0]" + } + } + ] +} diff --git a/shared/tools/shape/manipulation/enlarge-shape.noop.input.json b/shared/tools/shape/manipulation/enlarge-shape.noop.input.json new file mode 100644 index 00000000..1a541a66 --- /dev/null +++ b/shared/tools/shape/manipulation/enlarge-shape.noop.input.json @@ -0,0 +1,41 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "circle-002", + "geometry": { + "type": "Polygon", + "coordinates": [[ + [0.0, 50.009], + [0.01, 50.006], + [0.014, 50.0], + [0.01, 49.994], + [0.0, 49.991], + [-0.01, 49.994], + [-0.014, 50.0], + [-0.01, 50.006], + [0.0, 50.009] + ]] + }, + "properties": { + "kind": "CIRCLE", + "center": [0.0, 50.0], + "radius": 1000, + "label": "Search Area", + "style": { + "fill": true, + "fill_color": "#00FF00", + "fill_opacity": 0.2, + "stroke": true, + "color": "#00FF00", + "weight": 2, + "opacity": 1.0 + } + } + } + ], + "parameters": { + "scale_factor": 1.0 + } +} diff --git a/shared/tools/shape/manipulation/enlarge-shape.noop.output.json b/shared/tools/shape/manipulation/enlarge-shape.noop.output.json new file mode 100644 index 00000000..6c0011b6 --- /dev/null +++ b/shared/tools/shape/manipulation/enlarge-shape.noop.output.json @@ -0,0 +1,15 @@ +{ + "content": [ + { + "type": "resource", + "uri": "feature://circle-002", + "mimeType": "application/geo+json", + "text": "{\"type\":\"Feature\",\"id\":\"circle-002\",\"geometry\":{\"type\":\"Polygon\",\"coordinates\":[[[0.0,50.009],[0.01,50.006],[0.014,50.0],[0.01,49.994],[0.0,49.991],[-0.01,49.994],[-0.014,50.0],[-0.01,50.006],[0.0,50.009]]]},\"properties\":{\"kind\":\"CIRCLE\",\"center\":[0.0,50.0],\"radius\":1000,\"label\":\"Search Area\",\"style\":{\"fill\":true,\"fill_color\":\"#00FF00\",\"fill_opacity\":0.2,\"stroke\":true,\"color\":\"#00FF00\",\"weight\":2,\"opacity\":1.0}}}", + "annotations": { + "debrief:resultType": "mutation/shape/scaled", + "debrief:sourceFeatures": ["circle-002"], + "debrief:label": "Scaled 1 shape(s) by factor 1.0 from centroid" + } + } + ] +} diff --git a/specs/057-enlarge-shape/checklists/requirements.md b/specs/057-enlarge-shape/checklists/requirements.md new file mode 100644 index 00000000..668e723c --- /dev/null +++ b/specs/057-enlarge-shape/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Enlarge Shape Tool Spec + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-13 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All checklist items pass. The spec is ready for `/speckit.clarify` or `/speckit.plan`. +- UI Feature Validation section omitted — this is a tool specification, not a UI feature. +- The spec describes WHAT the tool does (scale shapes relative to an origin) without prescribing HOW to implement it. The #049 tool documentation model and algorithm pseudocode will be created as the deliverable, not as implementation detail in this spec. diff --git a/specs/057-enlarge-shape/data-model.md b/specs/057-enlarge-shape/data-model.md new file mode 100644 index 00000000..cd6f221c --- /dev/null +++ b/specs/057-enlarge-shape/data-model.md @@ -0,0 +1,99 @@ +# Data Model: Enlarge Shape Tool + +**Feature**: 057-enlarge-shape | **Date**: 2026-02-13 + +## Entities + +### ScaleParameters + +Tool input parameters provided in the FeatureCollection's `parameters` field. + +| Field | Type | Required | Default | Choices (presets) | Description | +|-------|------|----------|---------|-------------------|-------------| +| `scale_factor` | number | No | 3.0 | 0.25, 0.5, 1.5, 2.0, 3.0, 5.0 | Multiplicative scale factor. >1 enlarges, <1 shrinks, =1 no-op. Must be >= 0. Choices are presets; any non-negative number is accepted. | +| `origin` | [number, number] | No | computed centroid | — | Scale origin point as [longitude, latitude]. Defaults to geometric centroid of each shape. | + +**Validation rules**: +- `scale_factor` MUST be >= 0 (negative values return error) +- `scale_factor` choices are **presets** for frontend context menus — the tool accepts any non-negative numeric value, not only the listed choices +- `origin`, if provided, MUST be a valid [lon, lat] coordinate with lon in [-180, 180] and lat in [-90, 90] + +### Input: FeatureCollection + +Standard GeoJSON FeatureCollection containing annotation features to scale. + +```json +{ + "type": "FeatureCollection", + "features": [ ... ], + "parameters": { + "scale_factor": 3.0, + "origin": [lon, lat] + } +} +``` + +**Supported annotation kinds** (from `annotations.yaml`): + +| Kind | Geometry Type | Special Properties | +|------|--------------|-------------------| +| CIRCLE | Polygon | `center`: [lon, lat] — must be scaled with vertices | +| RECTANGLE | Polygon | None beyond geometry | +| LINE | LineString | None beyond geometry | +| TEXT | Point | Single coordinate — centroid is the point itself | +| VECTOR | LineString | `origin`: [lon, lat] — must be scaled; `range` and `bearing` — preserved | + +### Output: ToolResponse + +Standard ToolResponse envelope with mutation content items. + +| Field | Type | Description | +|-------|------|-------------| +| `content` | ContentItem[] | Array of scaled annotation features | +| `content[].type` | "resource" | Always "resource" for feature mutations | +| `content[].uri` | string | `feature://{feature-id}` | +| `content[].mimeType` | string | `application/geo+json` | +| `content[].text` | string | Serialized GeoJSON Feature (stringified JSON) | +| `content[].annotations` | object | Provenance metadata (see below) | + +### Annotations (Provenance) + +Required on each content item: + +| Annotation | Type | Description | +|------------|------|-------------| +| `debrief:resultType` | string | Always `mutation/shape/scaled` | +| `debrief:sourceFeatures` | string[] | IDs of input features that were scaled | +| `debrief:label` | string | Human-readable: `"Scaled {n} shape(s) by factor {scale_factor} from {origin_description}"` | + +### Error Response + +Returned when input is invalid. + +| Field | Type | Description | +|-------|------|-------------| +| `error.code` | number | `-32000` (standard MCP error) | +| `error.message` | string | Human-readable error description | +| `error.data.debrief:errorCategory` | string | `"invalid_input"` | +| `error.data.debrief:affectedFeatures` | string[] | IDs of features involved (empty if no features) | + +## State Transitions + +This tool has no state — it is a pure function. Input FeatureCollection goes in, ToolResponse comes out. No persistence, no side effects. + +## Relationships + +``` +FeatureCollection (input) + ├── features[]: Feature (annotation shapes) + │ ├── geometry: Polygon | LineString | Point + │ └── properties: { kind, center?, origin?, range?, bearing?, ... } + └── parameters: ScaleParameters + ├── scale_factor: number + └── origin?: [lon, lat] + +ToolResponse (output) + └── content[]: ContentItem + ├── text: serialized Feature (scaled geometry) + └── annotations: { resultType, sourceFeatures, label } +``` diff --git a/specs/057-enlarge-shape/evidence/spec-validation.md b/specs/057-enlarge-shape/evidence/spec-validation.md new file mode 100644 index 00000000..2991b9de --- /dev/null +++ b/specs/057-enlarge-shape/evidence/spec-validation.md @@ -0,0 +1,41 @@ +# Spec Validation: enlarge-shape.1.0.md + +**Date**: 2026-02-13 +**Spec**: `shared/tools/shape/manipulation/enlarge-shape.1.0.md` + +## 9-Section Checklist + +| # | Section | Present | Non-Empty | Notes | +|---|---------|---------|-----------|-------| +| 1 | Metadata (YAML frontmatter) | PASS | PASS | name: enlarge-shape, version: 1.0, category: shape/manipulation, status: draft | +| 2 | MCP | PASS | PASS | Description, when-to-use, parameters (scale_factor with presets, origin), returns | +| 3 | Inputs | PASS | PASS | Schema ref, constraints (6), defaults (2), parameter presets section | +| 4 | Outputs | PASS | PASS | ToolResponse schema, result type path `shape/scaled`, annotations (3 required) | +| 5 | Algorithm | PASS | PASS | Three functions: compute_centroid, scale_coordinate, enlarge_shape; per-kind handling | +| 6 | Edge Cases | PASS | PASS | 15 scenarios documented (exceeds 10+ requirement from spec.md) | +| 7 | Examples | PASS | PASS | Inline basic example with computation walkthrough, golden file table, error example | +| 8 | Changelog | PASS | PASS | 1.0 (2026-02-13): Initial release with 6 bullet points | +| 9 | References | PASS | PASS | Related tools (move-shape), schemas (2), template, external (1) | + +## Golden I/O Files + +| File | Valid JSON | Coordinates Verified | +|------|-----------|---------------------| +| enlarge-shape.basic-polygon.input.json | PASS | N/A (input) | +| enlarge-shape.basic-polygon.output.json | PASS | PASS — all vertices 3x from centroid | +| enlarge-shape.custom-origin.input.json | PASS | N/A (input) | +| enlarge-shape.custom-origin.output.json | PASS | PASS — origin vertex fixed, others 2x from origin | +| enlarge-shape.noop.input.json | PASS | N/A (input) | +| enlarge-shape.noop.output.json | PASS | PASS — coordinates identical to input | + +## Provenance Annotations + +| Example | resultType | sourceFeatures | label | +|---------|-----------|----------------|-------| +| basic-polygon | mutation/shape/scaled | ["rect-001"] | Scaled 1 shape(s) by factor 3.0 from centroid | +| custom-origin | mutation/shape/scaled | ["rect-002"] | Scaled 1 shape(s) by factor 2.0 from [-1.0, 51.0] | +| noop | mutation/shape/scaled | ["circle-002"] | Scaled 1 shape(s) by factor 1.0 from centroid | + +## Result + +**ALL CHECKS PASS** — Spec is complete and valid per #049 tool documentation model. diff --git a/specs/057-enlarge-shape/evidence/test-summary.md b/specs/057-enlarge-shape/evidence/test-summary.md new file mode 100644 index 00000000..ad03740a --- /dev/null +++ b/specs/057-enlarge-shape/evidence/test-summary.md @@ -0,0 +1,50 @@ +# Test Summary: Enlarge Shape Tool Spec + +**Date**: 2026-02-13 +**Feature**: 057-enlarge-shape + +## Validation Results + +### JSON Well-Formedness + +All 6 golden I/O files parse successfully as valid JSON: + +``` +enlarge-shape.basic-polygon.input.json VALID +enlarge-shape.basic-polygon.output.json VALID +enlarge-shape.custom-origin.input.json VALID +enlarge-shape.custom-origin.output.json VALID +enlarge-shape.noop.input.json VALID +enlarge-shape.noop.output.json VALID +``` + +### Coordinate Verification + +**Basic-polygon** (factor=3.0, centroid origin): +- Input centroid: [-0.75, 51.25] (arithmetic mean of 4 unique vertices) +- All 5 output vertices (including closing) verified: each is 3x farther from centroid than input +- Tolerance: exact match (0 error) + +**Custom-origin** (factor=2.0, origin=[-1.0, 51.0]): +- Origin vertex [-1.0, 51.0] remains fixed in output (0 displacement) +- All other vertices verified: each is 2x farther from origin than input +- Tolerance: exact match (0 error) + +**Noop** (factor=1.0): +- All 9 output coordinates exactly match input coordinates +- Center property [0.0, 50.0] unchanged +- Tolerance: exact match (0 error) + +### Spec Section Completeness + +All 9 required sections present and non-empty: +1. Metadata, 2. MCP, 3. Inputs, 4. Outputs, 5. Algorithm, 6. Edge Cases, 7. Examples, 8. Changelog, 9. References + +### Edge Case Coverage + +15 edge cases documented (requirement: 10+): +scale factor 0, negative factor, factor 1.0, large factors near poles, empty input, non-annotation features, no annotations after filter, multiple rings, CIRCLE center, VECTOR origin/range/bearing, TEXT point, closing vertex, custom origin at vertex, custom origin outside shape, antimeridian crossing + +## Overall Result + +**ALL VALIDATIONS PASS** diff --git a/specs/057-enlarge-shape/evidence/usage-example.md b/specs/057-enlarge-shape/evidence/usage-example.md new file mode 100644 index 00000000..e78038c4 --- /dev/null +++ b/specs/057-enlarge-shape/evidence/usage-example.md @@ -0,0 +1,66 @@ +# Usage Example: Scaling Formula Walkthrough + +**Feature**: 057-enlarge-shape + +## Scenario: Enlarge a Rectangle by Factor 3.0 + +An analyst has a rectangular exercise area defined by four vertices and wants to triple its size relative to its geometric center. + +### Step 1: Input + +A rectangle with vertices at: +``` +(-1.0, 51.0) (-0.5, 51.0) +(-1.0, 51.5) (-0.5, 51.5) +``` + +GeoJSON ring (counter-clockwise, closed): +```json +[[-1.0, 51.0], [-0.5, 51.0], [-0.5, 51.5], [-1.0, 51.5], [-1.0, 51.0]] +``` + +### Step 2: Compute Centroid + +The default origin is the arithmetic mean of unique vertices (exclude closing vertex): + +``` +centroid_lon = (-1.0 + -0.5 + -0.5 + -1.0) / 4 = -3.0 / 4 = -0.75 +centroid_lat = (51.0 + 51.0 + 51.5 + 51.5) / 4 = 205.0 / 4 = 51.25 +``` + +Centroid: **[-0.75, 51.25]** + +### Step 3: Scale Each Vertex + +Formula: `new_coord = origin + (vertex - origin) * scale_factor` + +| Vertex | lon calc | lat calc | Result | +|--------|----------|----------|--------| +| [-1.0, 51.0] | -0.75 + (-1.0 - -0.75) * 3.0 = -0.75 + (-0.75) = **-1.5** | 51.25 + (51.0 - 51.25) * 3.0 = 51.25 + (-0.75) = **50.5** | [-1.5, 50.5] | +| [-0.5, 51.0] | -0.75 + (-0.5 - -0.75) * 3.0 = -0.75 + (0.75) = **0.0** | 51.25 + (-0.25) * 3.0 = **50.5** | [0.0, 50.5] | +| [-0.5, 51.5] | -0.75 + (0.25) * 3.0 = **0.0** | 51.25 + (0.25) * 3.0 = **52.0** | [0.0, 52.0] | +| [-1.0, 51.5] | -0.75 + (-0.25) * 3.0 = **-1.5** | 51.25 + (0.25) * 3.0 = **52.0** | [-1.5, 52.0] | +| [-1.0, 51.0] | (closing = first) | | [-1.5, 50.5] | + +### Step 4: Verify + +- Original extent: 0.5° lon x 0.5° lat +- Scaled extent: 1.5° lon x 1.5° lat (exactly 3x in each dimension) +- Centroid of scaled shape: (-1.5+0.0+0.0+-1.5)/4 = -0.75, (50.5+50.5+52.0+52.0)/4 = 51.25 +- Centroid unchanged (as expected for centroid-based scaling) + +### Step 5: Output + +The tool returns a ToolResponse with the scaled feature and provenance: + +```json +{ + "annotations": { + "debrief:resultType": "mutation/shape/scaled", + "debrief:sourceFeatures": ["rect-001"], + "debrief:label": "Scaled 1 shape(s) by factor 3.0 from centroid" + } +} +``` + +This matches the golden example in `enlarge-shape.basic-polygon.output.json`. diff --git a/specs/057-enlarge-shape/media/linkedin-planning.md b/specs/057-enlarge-shape/media/linkedin-planning.md new file mode 100644 index 00000000..31b876e5 --- /dev/null +++ b/specs/057-enlarge-shape/media/linkedin-planning.md @@ -0,0 +1,13 @@ +How should a shape scale on a sphere? It depends on what you're willing to approximate. + +We just shipped the move-shape tool spec for Future Debrief, using Vincenty great-circle math to translate annotations. Now we're writing the sibling spec: enlarge-shape, which scales annotation shapes by a multiplicative factor relative to an origin point. + +The interesting design choice: move-shape needed spherical geometry because it translates by bearing and distance. Scaling is different -- it multiplies coordinate differences. For annotation shapes spanning a few kilometres, simple lat/lon interpolation introduces less than 0.1% error at mid-latitudes. Sometimes the straightforward approach is the right one. + +The spec covers five annotation kinds -- circles, rectangles, lines, vectors, text labels -- each with their own quirks. Vectors are the interesting case: the geometry and origin scale, but range and bearing stay fixed because they define what the vector means, not just where it sits. + +This is spec-only -- a Markdown document with pseudocode and golden I/O JSON fixtures, no code. Any future Python or TypeScript implementation validates against the same expected outputs. + +Read the full planning post: https://debrief.github.io/future/2026/02/13/planning-enlarge-shape-tool-spec.html + +#FutureDebrief #MaritimeAnalysis #OpenSource diff --git a/specs/057-enlarge-shape/media/linkedin-shipped.md b/specs/057-enlarge-shape/media/linkedin-shipped.md new file mode 100644 index 00000000..b6bb7f8d --- /dev/null +++ b/specs/057-enlarge-shape/media/linkedin-shipped.md @@ -0,0 +1,11 @@ +How should a shape scale on a sphere? Sometimes the answer is: don't overthink it. + +The enlarge-shape tool spec shipped this week for Future Debrief -- a language-neutral specification for scaling annotation shapes by a multiplicative factor. It's the sibling of the move-shape tool, which needed Vincenty great-circle math. Scaling is different: it multiplies coordinate differences, and for shapes spanning a few kilometres, simple linear interpolation in lat/lon introduces under 0.1% error. The straightforward approach turned out to be the correct one. + +The spec covers five annotation kinds, each with its own rules. Vectors are the interesting case again: geometry and origin scale, but range and bearing stay fixed because they define meaning, not position. Three golden I/O fixture pairs serve as the cross-language contract -- any future Python or TypeScript implementation either matches the expected output or it doesn't. + +Two of three shape manipulation tools now specified. Rotation is next. + +Read the full post: https://debrief.github.io/future/2026/02/13/shipped-enlarge-shape-tool-spec.html + +#FutureDebrief #MaritimeAnalysis #OpenSource diff --git a/specs/057-enlarge-shape/media/planning-post.md b/specs/057-enlarge-shape/media/planning-post.md new file mode 100644 index 00000000..d6a73585 --- /dev/null +++ b/specs/057-enlarge-shape/media/planning-post.md @@ -0,0 +1,52 @@ +--- +layout: future-post +title: "Planning: Enlarge Shape Tool Spec" +date: 2026-02-13 +track: [momentum] +author: Ian +reading_time: 4 +tags: [tracer-bullet, tool-specs, shape-manipulation] +excerpt: "Specifying how annotation shapes scale relative to an origin point using lat/lon interpolation" +--- + +## What We're Building + +Three days ago we shipped the move-shape tool spec -- translating annotations across the Earth's surface using Vincenty math. The natural follow-up: scaling those same shapes bigger or smaller. + +We're writing a language-neutral specification for an enlarge-shape tool. An analyst selects an annotation -- a circle marking an exercise area, a rectangle around a patrol zone, a vector indicating course -- and scales it by a multiplicative factor relative to an origin point. Factor of 3.0 triples the extent. Factor of 0.5 halves it. Factor of 1.0 leaves it untouched. The default origin is the shape's geometric centroid, but an analyst can pin the scaling to any point: a sensor location, a corner vertex, an arbitrary map coordinate. + +Like its sibling, this is a spec-only deliverable. One markdown document following the #049 tool documentation model (all nine sections), plus three golden I/O JSON fixture pairs. No Python. No TypeScript. Just a precise description of what any implementation must do, with test fixtures to prove it. + +## How It Fits + +The spec drops into `shared/tools/shape/manipulation/` alongside `move-shape.1.0.md`. Together they form the first two tools in the shape manipulation category -- translation and scaling. Rotation is the obvious third, completing the family of affine-like transformations for annotation shapes. + +Each tool spec follows the same #049 template: metadata, MCP descriptions, input/output schemas, pseudocode, edge cases, golden examples, changelog, references. Any future implementation in any language runs the golden input, compares the output, and either matches or doesn't. No ambiguity. + +## Key Decisions + +- **Linear interpolation, not great-circle math**: Move-shape needed Vincenty because it translates by bearing and distance -- inherently spherical parameters. Scaling is different. It multiplies the coordinate difference between each vertex and the origin. For annotation shapes spanning a few kilometres, the distortion from treating lat/lon as flat is under 0.1% at mid-latitudes. Simple and correct enough. + +- **Arithmetic mean centroid, not area centroid**: The "true" centroid of a polygon comes from the shoelace formula over the enclosed area. But Debrief annotations are typically convex shapes with 4-8 vertices, where the arithmetic mean of the vertices is nearly identical. It's also consistent across geometry types -- polygons, lines, and points all compute the same way. + +- **Scale factor of 0 is allowed**: Mathematically, scaling by 0 collapses every vertex to the origin point. The result is a degenerate geometry, but it's valid GeoJSON and the provenance records what happened. Undo still works. Treating 0 as an error felt overly restrictive. + +- **`mutation/shape/scaled` result type**: Follows the naming convention from move-shape's `mutation/shape/translated`. We considered `enlarged` and `resized`, but "scaled" is the precise geometric term and covers both growing and shrinking. + +- **Vectors: scale geometry and origin, preserve range and bearing**: A vector annotation's `range` and `bearing` define its semantic meaning -- course, wind direction, threat axis. Scaling repositions where the vector sits, but shouldn't change what it represents. Same approach move-shape takes with translation. + +- **Latitude clamping, longitude wrapping**: Extreme scale factors near the poles could push latitude past 90 degrees. Latitude gets clamped to [-90, 90] because wrapping doesn't make geometric sense there. Longitude wraps normally via `((lon + 180) mod 360) - 180`, consistent with RFC 7946. + +## What We'd Love Feedback On + +Three golden I/O pairs cover the key scenarios: basic polygon scaling from centroid, custom origin scaling, and scale factor 1.0 (no-op). The edge cases section documents zero factors, negative factors (error), very large factors near poles, and empty inputs. + +Questions worth considering: + +1. **Is arithmetic mean centroid adequate?** For convex annotations it's indistinguishable from the area centroid. If anyone works with heavily concave annotation shapes, the difference might matter. + +2. **Should scale factor 0 warn?** The spec currently returns a degenerate geometry silently (with provenance). An alternative: return the result but add a warning annotation. Would that be more useful than treating it as a normal operation? + +3. **Latitude clamping vs error**: When scaling pushes vertices past the poles, the spec clamps to [-90, 90]. This silently distorts the shape. Should extremely distorted results return an error instead, or is clamping with provenance sufficient? + +-> [Join the discussion](https://github.com/debrief/debrief-future/discussions) diff --git a/specs/057-enlarge-shape/media/shipped-post.md b/specs/057-enlarge-shape/media/shipped-post.md new file mode 100644 index 00000000..6614e7a4 --- /dev/null +++ b/specs/057-enlarge-shape/media/shipped-post.md @@ -0,0 +1,50 @@ +--- +layout: future-post +title: "Shipped: Enlarge Shape Tool Spec" +date: 2026-02-13 +track: [credibility] +author: Ian +reading_time: 3 +tags: [tracer-bullet, tool-specs, shape-manipulation] +excerpt: "Second shape manipulation spec: scaling annotations via linear interpolation with 3 golden fixtures" +--- + +## What We Built + +The second tool in the `shape/manipulation` family: a language-neutral specification for scaling annotation shapes by a multiplicative factor. An analyst selects a circle marking an exercise area, a rectangle around a patrol zone, a vector indicating a threat axis -- and scales it bigger or smaller relative to an origin point. The spec lives at `shared/tools/shape/manipulation/enlarge-shape.1.0.md`, following the same nine-section #049 template as its sibling move-shape. + +Three golden I/O fixture pairs define the contract. Basic-polygon: a rectangle scaled 3x from its geometric centroid, every vertex verified at exactly 3x the original distance. Custom-origin: scaling 2x from an explicit vertex, where that vertex stays fixed and everything else doubles its distance. Noop: scale factor 1.0, coordinates unchanged, provenance still recorded. + +No Python. No TypeScript. Just a precise description of what any implementation must do, with JSON fixtures to prove it. + +## How It Works + +The algorithm is simpler than move-shape's Vincenty math. Scaling multiplies the coordinate difference between each vertex and the origin: + +``` +new_lon = origin_lon + scale_factor * (old_lon - origin_lon) +new_lat = origin_lat + scale_factor * (old_lat - origin_lat) +``` + +This is linear interpolation in geographic coordinates. For annotation shapes spanning a few kilometres -- typical maritime exercise areas -- the distortion from treating lat/lon as flat is under 0.1% at mid-latitudes. Move-shape needed great-circle formulas because it translates by bearing and distance, inherently spherical parameters. Scaling is different: it is a ratio applied to coordinate differences, and the simpler approach is the correct one here. + +The default origin is the shape's arithmetic mean centroid -- the average of all vertex coordinates. We considered the area centroid from the shoelace formula, but for the convex shapes typical in Debrief annotations (4-8 vertices), the difference is negligible. The arithmetic mean also works identically for polygons, lines, and points. + +Each annotation kind has its own handling. Circles get their `center` property recomputed from the scaled vertices. Vectors have their `origin` repositioned, but `range` and `bearing` are preserved -- scaling changes where the vector sits, not what it means. Text labels scale as points. The `scale_factor` parameter declares preset choices [0.25, 0.5, 1.5, 2.0, 3.0, 5.0] so frontends can offer a context menu, while still accepting any non-negative numeric value. + +The result type is `mutation/shape/scaled`, with provenance recording the origin point, scale factor, and source feature IDs. + +## Lessons Learned + +The planning post asked three open questions: whether arithmetic mean centroid was adequate, whether scale factor 0 should warn, and whether latitude clamping was preferable to erroring. The spec landed on: yes (adequate for convex shapes), no (silent degenerate geometry with provenance), and yes (clamp to [-90, 90] with longitude wrapping to [-180, 180]). These felt like the least surprising behaviours. Undo still works in every case, and provenance records exactly what happened. + +Fifteen edge cases made it into the spec -- five more than the minimum. The interesting ones are the per-kind annotation rules. A circle's center must be recomputed, not just shifted. A vector's bearing must be left alone. A polygon's closing vertex (which duplicates the first) must track the scaled first vertex. These details are exactly what a spec-first approach is designed to capture before anyone writes a line of code. + +Writing this alongside move-shape confirmed that the #049 template scales well across tool types. The structure is the same -- metadata, MCP descriptions, inputs, outputs, algorithm, edge cases, examples, changelog, references -- but the content is genuinely different. The template guides without constraining. + +## What's Next + +Two of three shape manipulation tools are now specified: translate and scale. Rotation is the natural third, completing the set of affine-like transformations. Implementation of both move-shape and enlarge-shape in Python and TypeScript can proceed in parallel -- the golden fixtures define the acceptance criteria. + +> [See the spec](https://github.com/debrief/debrief-future/tree/main/shared/tools/shape/manipulation/enlarge-shape.1.0.md) +> [Test summary](https://github.com/debrief/debrief-future/tree/main/specs/057-enlarge-shape/evidence/test-summary.md) diff --git a/specs/057-enlarge-shape/plan.md b/specs/057-enlarge-shape/plan.md new file mode 100644 index 00000000..c11d107a --- /dev/null +++ b/specs/057-enlarge-shape/plan.md @@ -0,0 +1,91 @@ +# Implementation Plan: Enlarge Shape Tool Spec + +**Branch**: `claude/speckit-start-057-SEg6u` | **Date**: 2026-02-13 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/057-enlarge-shape/spec.md` + +## Summary + +Create a language-neutral tool specification for a shape scaling (enlarge/shrink) tool, following the #049 tool documentation model. The deliverable is a Markdown specification file (`enlarge-shape.1.0.md`) with all 9 required sections and at least 3 golden I/O example pairs (`.input.json` / `.output.json`). No Python or TypeScript code is produced — this is a specification-only feature. + +The tool scales annotation shapes (CIRCLE, RECTANGLE, LINE, TEXT, VECTOR) relative to an origin point by a multiplicative factor. The specification will live in `shared/tools/shape/manipulation/` alongside the existing `move-shape.1.0.md`. + +## Technical Context + +**Language/Version**: Markdown + JSON (specification documents, no executable code) +**Primary Dependencies**: #049 tool documentation model (TEMPLATE.md), #056 move-shape (sibling spec for reference patterns) +**Storage**: Filesystem only — Markdown spec + JSON golden example files in `shared/tools/shape/manipulation/` +**Testing**: Golden I/O validation (JSON comparison with floating-point tolerance) +**Target Platform**: N/A — specification documents consumed by future Python/TypeScript implementations +**Project Type**: Single (specification files only) +**Performance Goals**: N/A — spec only +**Constraints**: Offline-capable (Constitution Art. I), scaling in geographic coordinates (lat/lon), provenance required (Art. III) +**Scale/Scope**: 1 tool spec file (~300 lines), 3+ golden I/O example pairs (6+ JSON files) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Article | Requirement | Status | Notes | +|---------|------------|--------|-------| +| I. Defence-Grade Reliability | Offline by default | PASS | Spec requires offline operation, no network dependencies | +| I.4 Reproducibility | Same inputs → same results | PASS | Deterministic scaling algorithm (linear interpolation) | +| II. Schema Integrity | LinkML master schemas | PASS | Spec references existing `annotations.yaml` schema classes | +| III. Data Sovereignty | Provenance always | PASS | FR-008, FR-017 require provenance in every output | +| IV. Architectural Boundaries | Services never touch UI | PASS | Tool spec is a pure data transformation, no UI | +| VI. Testing | Services require unit tests | PASS | Golden I/O examples serve as test fixtures for future implementation | +| VII. Test-Driven AI | Tests before implementation | PASS | Golden examples define expected behavior before any code exists | +| VIII. Documentation | Specs before code | PASS | This IS the spec — code implementation is a separate future task | +| IX. Dependencies | Minimal, vetted | PASS | No external dependencies — standard library math only | +| XIII. Contribution Standards | Atomic commits | PASS | Single feature, single spec file + golden examples | + +**Gate result: PASS** — No violations. All constitutional articles satisfied. + +## Project Structure + +### Documentation (this feature) + +```text +specs/057-enlarge-shape/ +├── spec.md # Feature specification (completed) +├── plan.md # This file +├── research.md # Phase 0: scaling algorithm research +├── data-model.md # Phase 1: entity model for tool I/O +├── quickstart.md # Phase 1: how to write and validate the spec +├── checklists/ +│ └── requirements.md # Spec quality checklist (completed) +└── media/ + ├── planning-post.md + └── linkedin-planning.md +``` + +### Source Code (repository root) + +```text +shared/tools/shape/manipulation/ +├── move-shape.1.0.md # Existing sibling spec (reference) +├── move-shape.basic-polygon.input.json # Existing golden example +├── move-shape.basic-polygon.output.json +├── move-shape.vector.input.json +├── move-shape.vector.output.json +├── enlarge-shape.1.0.md # NEW: Tool specification +├── enlarge-shape.basic-polygon.input.json # NEW: Scale polygon 3x from centroid +├── enlarge-shape.basic-polygon.output.json +├── enlarge-shape.custom-origin.input.json # NEW: Scale from explicit origin +├── enlarge-shape.custom-origin.output.json +├── enlarge-shape.noop.input.json # NEW: Scale factor 1.0 +└── enlarge-shape.noop.output.json +``` + +**Structure Decision**: Spec-only feature. All deliverables are Markdown and JSON files placed in the existing `shared/tools/shape/manipulation/` directory, following the exact conventions established by `move-shape.1.0.md`. + +## Media Components + +None — specification/infrastructure feature with no visual components. + +## Storybook E2E Testing + +None — no interactive UI components. + +## Complexity Tracking + +No violations to justify — all constitutional gates pass. diff --git a/specs/057-enlarge-shape/quickstart.md b/specs/057-enlarge-shape/quickstart.md new file mode 100644 index 00000000..465ead24 --- /dev/null +++ b/specs/057-enlarge-shape/quickstart.md @@ -0,0 +1,62 @@ +# Quickstart: Enlarge Shape Tool Spec + +**Feature**: 057-enlarge-shape | **Date**: 2026-02-13 + +## What This Feature Delivers + +A language-neutral tool specification file (`enlarge-shape.1.0.md`) and golden I/O example files, placed in `shared/tools/shape/manipulation/`. No executable code. + +## How to Implement + +### Step 1: Author the Tool Spec + +Create `shared/tools/shape/manipulation/enlarge-shape.1.0.md` following the 9-section template from `shared/tools/TEMPLATE.md`. Use `move-shape.1.0.md` in the same directory as the primary reference. + +**Sections to fill**: +1. **Metadata** — YAML frontmatter: `name: enlarge-shape`, `version: 1.0`, `category: shape/manipulation`, `status: draft` +2. **MCP** — LLM-optimized description, parameters, returns +3. **Inputs** — Schema reference to `annotations.yaml`, constraints, defaults (`scale_factor: 3.0` with preset choices `[0.25, 0.5, 1.5, 2.0, 3.0, 5.0]`, `origin: centroid`) +4. **Outputs** — ToolResponse with `mutation/shape/scaled` result type +5. **Algorithm** — Pseudocode for centroid computation, coordinate scaling, kind-specific handling +6. **Edge Cases** — Table covering 10+ scenarios (see spec.md and research.md) +7. **Examples** — Inline basic example + references to golden files +8. **Changelog** — `1.0 (2026-02-13): Initial release` +9. **References** — Links to move-shape, annotations.yaml, TEMPLATE.md + +### Step 2: Compute Golden Example Values + +For each golden example, manually compute the expected output coordinates: + +**Scaling formula** (per vertex): +``` +new_lon = origin_lon + (vertex_lon - origin_lon) * scale_factor +new_lat = origin_lat + (vertex_lat - origin_lat) * scale_factor +``` + +Where `origin` defaults to the arithmetic mean of all vertices (excluding the closing vertex for closed polygons). + +### Step 3: Create Golden I/O Files + +Create 3 pairs in `shared/tools/shape/manipulation/`: + +| Example | Input | Output | Validates | +|---------|-------|--------|-----------| +| basic-polygon | Rectangle, factor 3.0, default centroid | Scaled rectangle, provenance | Core scaling from centroid | +| custom-origin | Polygon, factor 2.0, explicit origin | Scaled polygon, origin vertex fixed | Custom origin support | +| noop | Circle, factor 1.0 | Unchanged circle, provenance | Identity transformation | + +### Step 4: Validate + +1. Verify JSON is well-formed (parse all `.input.json` and `.output.json` files) +2. Verify output coordinates match hand-computed values within floating-point tolerance (1e-10) +3. Verify all 9 spec sections are present and non-empty +4. Verify provenance annotations include `debrief:resultType`, `debrief:sourceFeatures`, `debrief:label` + +## Key Decisions (from research.md) + +- **Linear interpolation** of lat/lon (not Vincenty) — adequate for local-scale annotation shapes +- **Arithmetic mean centroid** (not area centroid) — simpler, consistent across geometry types +- **Scale factor 0 allowed** — collapses to origin, returns degenerate geometry with provenance +- **Result type**: `mutation/shape/scaled` +- **Vector handling**: Scale geometry + origin, preserve range + bearing +- **Scale factor presets**: `[0.25, 0.5, 1.5, 2.0, 3.0, 5.0]` — preset choices for frontend context menus (matches move-shape `distance_km` pattern); any non-negative value still accepted diff --git a/specs/057-enlarge-shape/research.md b/specs/057-enlarge-shape/research.md new file mode 100644 index 00000000..499106f3 --- /dev/null +++ b/specs/057-enlarge-shape/research.md @@ -0,0 +1,83 @@ +# Research: Enlarge Shape Tool Spec + +**Feature**: 057-enlarge-shape | **Date**: 2026-02-13 + +## Research Questions + +### R1: Scaling Algorithm — Geographic Coordinates vs Projected + +**Decision**: Use simple linear interpolation of lat/lon differences (same approach as move-shape) + +**Rationale**: The move-shape sibling tool (`move-shape.1.0.md`) uses Vincenty destination formula for translation, which accounts for great-circle geometry. However, scaling is fundamentally different — it multiplies the *difference* between each vertex and the origin. For the typical Debrief use case (maritime exercise areas spanning tens of kilometres), the distortion from treating lat/lon as a flat coordinate system is negligible (<0.1% at mid-latitudes). Using simple linear interpolation keeps the algorithm straightforward and deterministic. + +**Alternatives considered**: +- **Haversine/Vincenty scaling**: Compute bearing and distance from origin to each vertex, then multiply the distance by the scale factor and recompute the destination point. More accurate at large scales and near poles, but significantly more complex. The move-shape tool uses Vincenty for translation because direction and distance are the natural parameters; for scaling, the linear approach is more natural. +- **Projected coordinate scaling**: Project to a local coordinate system (e.g., UTM), scale, then back-project. Accurate but requires projection libraries (violates Constitution Art. IX — minimal dependencies) and is overkill for annotation shapes. + +### R2: Centroid Computation Method + +**Decision**: Arithmetic mean of vertices (excluding closing vertex for closed polygons) + +**Rationale**: The geometric centroid of a polygon is technically the centroid of the enclosed area (computed via the shoelace formula), not the arithmetic mean of vertices. However, for the annotation shapes in Debrief (typically 4-8 vertices, roughly convex), the arithmetic mean is: +1. Simpler to implement and verify +2. Nearly identical to the area centroid for convex shapes +3. Consistent across all geometry types (Polygon, LineString, Point) +4. Deterministic and easy to validate in golden examples + +**Alternatives considered**: +- **Area centroid (shoelace formula)**: More geometrically correct for concave polygons, but adds complexity for minimal benefit given Debrief's annotation shapes. +- **Bounding box center**: Simple but skewed for irregular shapes — vertices near one edge would pull the center off. + +### R3: Handling VECTOR Annotations During Scaling + +**Decision**: Scale the geometry coordinates and origin point, but preserve `range` and `bearing` properties + +**Rationale**: A VECTOR annotation represents a directional indicator (e.g., course or wind direction). Its `origin` is the anchor point, `bearing` is the direction, and `range` is the length. When scaling, the entire shape grows/shrinks relative to the scaling origin, so the geometry coordinates and the `origin` property must be updated. However, `range` and `bearing` define the vector's semantic meaning and should be preserved — the vector still points the same direction and represents the same magnitude. This is consistent with how move-shape handles vectors (translates origin, preserves range/bearing). + +**Alternatives considered**: +- **Scale range as well**: Would change the vector's semantic meaning, which is undesirable for a geometric transformation. +- **Recompute from scaled geometry**: Circular — the geometry is derived from origin + range + bearing, so scaling the geometry and then recomputing would be inconsistent. + +### R4: Result Type Path for Scaling + +**Decision**: Use `mutation/shape/scaled` as the result type + +**Rationale**: Follows the naming conventions from TEMPLATE.md. The operation modifies existing features (mutation), operates on shapes (shape domain), and the specific action is scaling (scaled). This is analogous to move-shape's `mutation/shape/translated`. + +**Alternatives considered**: +- `mutation/shape/enlarged`: Too specific — the tool also handles shrinking (factor < 1) and no-op (factor = 1). +- `mutation/shape/resized`: Acceptable, but "scaled" is the more precise geometric term. + +### R5: Scale Factor of Zero Behavior + +**Decision**: Allow scale factor of 0 — collapses shape to origin point, returns degenerate geometry + +**Rationale**: A scale factor of 0 is mathematically well-defined: every vertex becomes the origin point. While the resulting geometry is degenerate (zero area/length), it's a valid GeoJSON geometry. Returning it with provenance allows the user to undo the operation. This is preferable to treating 0 as an error because: +1. The algorithm handles it naturally (no special case needed) +2. The user may intentionally want to collapse a shape to a point +3. Provenance records what happened, so it's recoverable + +**Alternatives considered**: +- **Return error**: Overly restrictive — 0 is a valid multiplier. +- **Return empty collection**: Inconsistent — there IS a result, just a degenerate one. + +### R6: Scale Factor Parameter Presets + +**Decision**: Declare `scale_factor` as `type="number"` with `choices=[0.25, 0.5, 1.5, 2.0, 3.0, 5.0]` preset values + +**Rationale**: Following the pattern established by move-shape's `distance_km` parameter (which has `choices=[1, 2, 5, 10, 20, 50]`), the scale_factor should offer common presets so frontends can present a context menu for quick selection. The presets include both shrink values (0.25, 0.5) and enlarge values (1.5, 2.0, 3.0, 5.0) to cover common analyst needs. The default remains 3.0. Any non-negative numeric value is still accepted via custom input — presets are convenience, not constraints. + +**Alternatives considered**: +- **Enum-only**: Restrict to preset values only. Too limiting — analysts may need precise factors like 1.8 or 4.5. +- **No presets**: Require typed numeric input. Poor UX for the common case where standard scaling factors suffice. +- **Schema-defined param_type**: Feature #091 will introduce `param_type` referencing LinkML enums. For now, inline `choices` is the correct approach per the current `ToolParameter` model. + +### R7: Latitude Clamping Strategy + +**Decision**: Clamp scaled latitude values to [-90, 90] range + +**Rationale**: If a shape near the poles is scaled with a large factor, vertices could exceed valid latitude bounds. Since latitude outside [-90, 90] is meaningless in geographic coordinates, clamping is the correct approach. Longitude wrapping (to [-180, 180]) follows the same pattern as move-shape. + +**Alternatives considered**: +- **Return error for out-of-bounds**: Too restrictive — partial scaling is better than no result. +- **Wrap latitude**: Latitude doesn't wrap — 91° latitude is not equivalent to 89° S. diff --git a/specs/057-enlarge-shape/spec.md b/specs/057-enlarge-shape/spec.md index 4c4cfaab..00623d0a 100644 --- a/specs/057-enlarge-shape/spec.md +++ b/specs/057-enlarge-shape/spec.md @@ -1,142 +1,131 @@ # Feature Specification: Enlarge Shape Tool Spec **Feature Branch**: `057-enlarge-shape` -**Created**: 2026-02-10 +**Created**: 2026-02-13 **Status**: Draft -**Input**: User description: "Add enlarge shape tool spec (requires #049)" +**Input**: User description: "Add enlarge shape tool spec — create a language-neutral tool specification (following #049 tool documentation model) for a shape scaling tool" ## User Scenarios & Testing *(mandatory)* -### User Story 1 - Scale up a polygon annotation (Priority: P1) +### User Story 1 - Scale Shape Up from Centroid (Priority: P1) -An analyst has a rectangle or circle annotation representing an exercise area that needs to be enlarged. They invoke the enlarge-shape tool with a scale factor (e.g., 2.0) and the shape doubles in size, scaling outward from its geometric centroid. All vertices move proportionally away from the center. +An analyst has a polygon annotation (e.g., an exercise area) on the map and needs to enlarge it to cover a wider region. They invoke the enlarge shape tool with a scale factor of 3.0, using the default geometric centroid as the origin. All vertices scale outward from the center, tripling the shape's extent. -**Why this priority**: Polygon shapes (circles, rectangles) are the most common annotations that need resizing. Scaling from centroid is the natural default behavior. +**Why this priority**: This is the core use case — scaling a shape relative to its geometric center with the default parameters. It validates the fundamental scaling algorithm and covers the most common analyst workflow. -**Independent Test**: Can be verified by providing a Polygon FeatureCollection, running the enlarge-shape algorithm with scale_factor=2.0, and checking that all coordinates have moved to twice their original distance from the centroid. +**Independent Test**: Can be fully tested by providing a polygon FeatureCollection with `scale_factor=3.0` and verifying all output coordinates are 3x farther from the centroid than the originals. **Acceptance Scenarios**: -1. **Given** a FeatureCollection containing a RectangleAnnotation centered at [0, 50], **When** enlarge-shape is invoked with scale_factor=2.0 (default origin=centroid), **Then** all 4 corners move to twice their original distance from the centroid, and the rectangle is twice its original size. -2. **Given** a FeatureCollection containing a CircleAnnotation with radius 1000m, **When** enlarge-shape is invoked with scale_factor=3.0, **Then** the polygon vertices scale outward from the center and the `radius` property is updated to 3000m. +1. **Given** a FeatureCollection containing a single polygon annotation with known vertices, **When** the enlarge shape tool is invoked with `scale_factor=3.0` and no explicit origin, **Then** each vertex is repositioned 3x farther from the computed geometric centroid, and the output includes provenance recording the origin and scale factor. +2. **Given** a circle annotation with a `center` property, **When** scaled by factor 2.0 from centroid, **Then** the polygon vertices scale outward and the `center` property remains at the centroid (unchanged since origin equals centroid). +3. **Given** a rectangle annotation, **When** scaled by factor 1.5 from centroid, **Then** all ring vertices are repositioned 1.5x farther from the centroid and the shape retains its rectangular proportions. --- -### User Story 2 - Scale from a custom origin point (Priority: P2) +### User Story 2 - Scale Shape from Custom Origin (Priority: P2) -An analyst wants to scale a shape relative to a specific point rather than the centroid. For example, scaling a rectangle from its bottom-left corner, keeping that corner fixed while the rest of the shape grows. +An analyst wants to scale a shape relative to a specific point (e.g., a sensor location or a corner of the shape) rather than the geometric centroid. They provide an explicit `origin` parameter and a scale factor. All vertices move relative to that custom origin point. -**Why this priority**: Custom origin provides flexibility for precise positioning, but the centroid default covers most use cases. +**Why this priority**: Custom origin scaling enables more sophisticated analyst workflows, such as anchoring one edge of a shape while expanding the other side. This extends the core algorithm with a user-specified reference point. -**Independent Test**: Provide a shape with an explicit origin parameter, verify the origin point remains fixed and all other points scale relative to it. +**Independent Test**: Can be fully tested by providing a polygon with an explicit origin point and verifying vertices are repositioned relative to that origin rather than the centroid. **Acceptance Scenarios**: -1. **Given** a RectangleAnnotation and origin=[0, 50] (a corner), **When** enlarge-shape is invoked with scale_factor=2.0, **Then** the corner at [0, 50] stays fixed and all other vertices move to twice their distance from that point. -2. **Given** a LineAnnotation with origin at one endpoint, **When** enlarge-shape is invoked with scale_factor=0.5, **Then** the line shrinks toward that endpoint. +1. **Given** a polygon annotation and an explicit origin at one of its vertices, **When** scaled by factor 2.0, **Then** the vertex at the origin remains fixed while all other vertices move 2x farther away from it. +2. **Given** a line annotation and an explicit origin outside the shape, **When** scaled by factor 0.5, **Then** all line coordinates move halfway toward the origin point, shrinking the shape. --- -### User Story 3 - Shrink a shape (Priority: P3) +### User Story 3 - No-Op Scale Factor (Priority: P3) -An analyst uses a scale factor less than 1 to reduce a shape. For example, shrinking an oversized danger area annotation to its correct proportional size. +An analyst accidentally invokes the enlarge tool with a scale factor of 1.0. The system returns the shape unchanged, ensuring no data corruption from identity transformations. -**Why this priority**: Shrinking is the inverse of enlarging and uses the same algorithm, just with factor < 1. +**Why this priority**: Edge case safety — confirms the tool handles identity transformations correctly and produces valid provenance even when no geometric change occurs. -**Independent Test**: Provide a shape with scale_factor=0.5, verify all vertices move to half their original distance from the origin. +**Independent Test**: Can be fully tested by invoking with `scale_factor=1.0` and verifying output coordinates exactly match input coordinates. **Acceptance Scenarios**: -1. **Given** a CircleAnnotation with radius 2000m, **When** enlarge-shape is invoked with scale_factor=0.5, **Then** the radius becomes 1000m and all polygon vertices move to half their distance from center. +1. **Given** any annotation feature, **When** the enlarge shape tool is invoked with `scale_factor=1.0`, **Then** all coordinates remain unchanged and provenance still records the transformation with factor 1.0. --- -### Edge Cases +### User Story 4 - Shrink Shape (Priority: P3) -- What happens when scale_factor is 1.0? Shape should remain unchanged (no-op mutation). -- What happens when scale_factor is 0? All points collapse to the origin (degenerate shape). -- What happens with very large scale factors (e.g., 1000)? Coordinates may wrap or exceed valid lat/lon bounds. -- What happens near the poles? Geographic scaling must handle latitude distortion. -- What happens with an empty feature collection? Return an error response. -- What happens with non-annotation features (e.g., TRACK features)? Skip them or return an error. -- What happens with a CircleAnnotation? Both polygon vertices AND the `radius` property must be scaled; `center` stays at origin (or moves if scaling from non-center origin). -- What happens with a VectorAnnotation? The `range` property must be scaled; `origin` moves relative to the scale origin; `bearing` is preserved. +An analyst needs to reduce a shape's size. They invoke the tool with a scale factor less than 1.0 (e.g., 0.5), which moves all vertices closer to the origin, effectively shrinking the shape. -## Requirements *(mandatory)* +**Why this priority**: Shrinking is the inverse of enlarging and uses the same algorithm, but should be explicitly validated to confirm factors < 1.0 work correctly. -### Functional Requirements +**Independent Test**: Can be fully tested by providing a polygon with `scale_factor=0.5` and verifying all vertices are halfway between their original positions and the origin. -- **FR-001**: Tool spec MUST follow the #049 tool documentation model with all 9 required sections (metadata, description, MCP, inputs, outputs, algorithm, edge cases, examples, changelog). -- **FR-002**: Algorithm MUST scale in geographic coordinates relative to a specified origin point. -- **FR-003**: Tool MUST accept `scale_factor` parameter as a positive multiplicative factor (default: 3.0). Factor > 1 enlarges, factor < 1 shrinks, factor = 1 is no-op. -- **FR-004**: Tool MUST accept `origin` parameter as [longitude, latitude] (default: geometric centroid of the shape). -- **FR-005**: Tool MUST scale all geometry coordinates (Point, LineString, Polygon vertices) relative to the origin point. -- **FR-006**: For CircleAnnotation features, tool MUST also update the `radius` property (radius * scale_factor) and the `center` property if it moves relative to the scale origin. -- **FR-007**: For VectorAnnotation features, tool MUST update the `range` property (range * scale_factor) and recompute geometry endpoint; `bearing` is preserved. -- **FR-008**: Tool MUST record provenance annotations including origin and scale_factor applied. -- **FR-009**: Tool MUST clamp latitude to [-90, 90] and normalize longitude to [-180, 180] after scaling. -- **FR-010**: Tool MUST produce golden I/O example files (`.input.json` / `.output.json`). -- **FR-011**: Tool MUST return a mutation-type ToolResponse with `mutation/shape/scaled` result type. -- **FR-012**: Tool MUST handle all annotation kinds: CIRCLE, RECTANGLE, LINE, TEXT, VECTOR. -- **FR-013**: Tool MUST reject scale_factor <= 0 with an error response. - -### Key Entities - -- **Shape Feature**: Any GeoJSON Feature with annotation kind (CIRCLE, RECTANGLE, LINE, TEXT, VECTOR). Each has geometry coordinates and kind-specific properties. -- **Scale Parameters**: Scale factor (positive float) and origin point ([lon, lat]). -- **Geometric Centroid**: Computed as the arithmetic mean of all vertex coordinates (for the default origin). +**Acceptance Scenarios**: -## Success Criteria *(mandatory)* +1. **Given** a polygon annotation, **When** the tool is invoked with `scale_factor=0.5` and default origin, **Then** each vertex is repositioned to the midpoint between its original position and the centroid. -### Measurable Outcomes - -- **SC-001**: Tool spec file exists at `shared/tools/shape/manipulation/enlarge-shape.1.0.md` with all 9 required sections. -- **SC-002**: At least 2 golden I/O example pairs exist (e.g., `enlarge-shape.basic-polygon.input.json` / `.output.json` and `enlarge-shape.shrink.input.json` / `.output.json`). -- **SC-003**: Algorithm pseudocode is language-neutral and covers all 5 annotation kinds. -- **SC-004**: Edge cases table covers at minimum: empty input, scale_factor=1, scale_factor=0, large factors, non-shape features. -- **SC-005**: Provenance annotations include origin and scale_factor in the label. +--- -## Deliverables +### Edge Cases -| Deliverable | Path | -|-------------|------| -| Tool spec | `shared/tools/shape/manipulation/enlarge-shape.1.0.md` | -| Golden example (basic polygon) | `shared/tools/shape/manipulation/enlarge-shape.basic-polygon.input.json` | -| Golden example (basic polygon output) | `shared/tools/shape/manipulation/enlarge-shape.basic-polygon.output.json` | -| Golden example (shrink) | `shared/tools/shape/manipulation/enlarge-shape.shrink.input.json` | -| Golden example (shrink output) | `shared/tools/shape/manipulation/enlarge-shape.shrink.output.json` | +- What happens when the scale factor is 0? All vertices collapse to the origin point — the shape degenerates to a single point. The tool should return the degenerate geometry with provenance. +- What happens when the scale factor is negative? Negative factors are invalid. The tool returns an error response. +- What happens when a very large scale factor (e.g., 1000) is applied to shapes near the poles? Coordinates may exceed valid latitude bounds ([-90, 90]). The tool must clamp latitude to valid range. +- What happens with an empty FeatureCollection? The tool returns an error indicating no features to process. +- What happens when the FeatureCollection contains non-annotation features? Non-annotation features are silently skipped; only annotation kinds (CIRCLE, RECTANGLE, LINE, TEXT, VECTOR) are processed. +- What happens with a polygon that has multiple rings (holes)? All rings (exterior and interior) are scaled relative to the same origin. -## Technical Notes +## Requirements *(mandatory)* -### Scaling Formula +### Functional Requirements -For each vertex (lon, lat) relative to origin (olon, olat): +- **FR-001**: Tool MUST accept a FeatureCollection containing annotation features and scale parameters (`origin`, `scale_factor`) +- **FR-002**: Tool MUST compute the geometric centroid of each shape as the default origin when no explicit origin is provided +- **FR-003**: Tool MUST scale all vertex coordinates relative to the origin by the given scale factor using geographic coordinate math (lat/lon differences multiplied by factor) +- **FR-004**: Tool MUST support all five annotation kinds: CIRCLE, RECTANGLE, LINE, TEXT, VECTOR +- **FR-005**: Tool MUST update the `center` property of CIRCLE annotations when scaling (recalculate from scaled vertices or scale the center point itself) +- **FR-006**: Tool MUST update the `origin` property of VECTOR annotations when scaling +- **FR-007**: Tool MUST preserve the `range` and `bearing` properties of VECTOR annotations (only the origin point changes, not the vector geometry definition) +- **FR-008**: Tool MUST return a ToolResponse with `mutation/shape/scaled` result type and provenance annotations including source feature IDs, origin used, and scale factor +- **FR-009**: Tool MUST return shapes unchanged when `scale_factor` is 1.0 (identity/no-op) +- **FR-010**: Tool MUST return an error for negative scale factors +- **FR-011**: Tool MUST return an error for empty input or input with no annotation features +- **FR-012**: Tool MUST silently skip non-annotation features in the input collection +- **FR-013**: Tool MUST clamp output latitude to [-90, 90] range to handle extreme scaling near poles +- **FR-014**: Tool MUST normalise output longitude to [-180, 180] range +- **FR-015**: Tool MUST follow the #049 tool documentation model with all 9 required sections +- **FR-016**: Tool MUST include golden I/O example files (`.input.json` and `.output.json`) for validation +- **FR-017**: Tool MUST record provenance including the origin point and scale factor used in the transformation +- **FR-018**: Tool MUST use a default scale factor of 3.0 when none is provided +- **FR-020**: Tool MUST declare `scale_factor` with preset choices (e.g., 0.25, 0.5, 1.5, 2.0, 3.0, 5.0) so frontends can present a selection menu before execution, while still accepting any non-negative numeric value via custom input +- **FR-019**: Tool MUST work entirely offline with no network dependencies -``` -new_lon = olon + (lon - olon) * scale_factor -new_lat = olat + (lat - olat) * scale_factor -``` +### Key Entities -Then clamp: `new_lat = clamp(new_lat, -90, 90)` and normalize: `new_lon = normalize(new_lon, -180, 180)`. +- **Annotation Feature**: A GeoJSON Feature with a `kind` property indicating its annotation type (CIRCLE, RECTANGLE, LINE, TEXT, VECTOR). Contains geometry coordinates and type-specific properties (center, origin, radius, bearing, range). +- **Scale Parameters**: The tool's input configuration consisting of an `origin` point (lat/lon, defaults to geometric centroid) and a `scale_factor` (multiplicative number, defaults to 3.0). +- **ToolResponse**: The standardised response envelope containing content items with result type annotations and provenance metadata. +- **Geometric Centroid**: The arithmetic mean of all vertex coordinates in a shape, used as the default scaling origin. -Note: This is a simple geographic coordinate scaling. For shapes spanning large areas, the distortion from Mercator-like scaling is acceptable for annotation purposes. +## Success Criteria *(mandatory)* -### Centroid Computation +### Measurable Outcomes -For Polygon: average of exterior ring coordinates (excluding closing point). -For LineString: average of all coordinates. -For Point: the point itself (scaling a point from its own centroid is a no-op). +- **SC-001**: Tool specification contains all 9 required sections per the #049 tool documentation model (Metadata, MCP, Inputs, Outputs, Algorithm, Edge Cases, Examples, Changelog, References) +- **SC-002**: At least 3 golden I/O example pairs are provided covering: basic polygon scaling, custom origin scaling, and scale factor of 1.0 (no-op) +- **SC-003**: All golden I/O examples produce identical outputs when processed by both Python and TypeScript implementations (cross-language validation) +- **SC-004**: Edge cases documented cover at minimum: scale factor of 0, negative scale factor, scale factor of 1, very large scale factors, empty input, non-annotation features, and shapes near geographic poles +- **SC-005**: Provenance annotations on every output feature record the origin point and scale factor used +- **SC-006**: The spec passes the existing tool-spec validation checklist without modifications -### Annotation Kind to Property Updates +### Assumptions -| Kind | Geometry Type | Extra Properties to Update | -|------|---------------|---------------------------| -| CIRCLE | Polygon | `center` (recompute from origin), `radius` (multiply by scale_factor) | -| RECTANGLE | Polygon | None | -| LINE | LineString | None | -| TEXT | Point | None | -| VECTOR | LineString | `origin` (recompute from scale origin), `range` (multiply by scale_factor), `bearing` preserved | +- Scaling operates in geographic coordinates (lat/lon) using simple linear interpolation of coordinate differences, consistent with the approach used by the move-shape tool for local-scale operations. This is acceptable for typical maritime exercise areas where shapes span small geographic extents. +- The geometric centroid is computed as the arithmetic mean of polygon exterior ring vertices (excluding the closing vertex that duplicates the first). For LineString geometries, it is the mean of all coordinate points. For Point geometries (TEXT annotations), the centroid is the point itself. +- VECTOR annotations have their geometry scaled like any other LineString, but `range` and `bearing` properties are preserved since they define the vector's directional meaning independently of absolute position. +- The tool specification is the deliverable — Python and TypeScript implementations are out of scope for this feature and will be handled by a separate implementation task. ### Dependencies -- Requires #049 (tool documentation model) - **complete** +- **#049 - Tool Documentation Model**: The template, 9-section structure, and golden I/O conventions that this spec must follow. This dependency is already complete. +- **#056 - Move Shape**: Sibling tool in `shape/manipulation` category. The enlarge-shape spec follows the same patterns and conventions established by move-shape. diff --git a/specs/057-enlarge-shape/tasks.md b/specs/057-enlarge-shape/tasks.md new file mode 100644 index 00000000..d4cd452f --- /dev/null +++ b/specs/057-enlarge-shape/tasks.md @@ -0,0 +1,228 @@ +# Tasks: Enlarge Shape Tool Spec + +**Input**: Design documents from `/specs/057-enlarge-shape/` +**Prerequisites**: plan.md (complete), spec.md (complete), research.md (complete), data-model.md (complete), quickstart.md (complete) + +**Tests**: No executable tests — this is a spec-only feature. Validation is via JSON well-formedness and manual coordinate verification against the scaling formula. + +**Organization**: Tasks grouped by user story to enable independent implementation. Each story adds golden I/O examples that validate specific scaling scenarios. + +--- + +## Evidence Requirements + +**Evidence Directory**: `specs/057-enlarge-shape/evidence/` +**Media Directory**: `specs/057-enlarge-shape/media/` + +### Planned Artifacts + +| Artifact | Description | Captured When | +|----------|-------------|---------------| +| test-summary.md | JSON validation results, section completeness check | After all spec + golden files created | +| usage-example.md | Walkthrough of scaling formula applied to basic-polygon example | After basic-polygon golden pair created | +| spec-validation.md | 9-section checklist confirming all sections present and non-empty | After spec file complete | + +### Media Content + +| Artifact | Description | Created When | +|----------|-------------|--------------| +| media/planning-post.md | Blog post announcing the feature | During /speckit.plan (done) | +| media/linkedin-planning.md | LinkedIn summary for planning | During /speckit.plan (done) | +| media/shipped-post.md | Blog post celebrating completion | During Polish phase | +| media/linkedin-shipped.md | LinkedIn summary for shipped | During Polish phase | + +### PR Creation + +| Action | Description | Created When | +|--------|-------------|--------------| +| Feature PR | PR in debrief-future with evidence | Final task in Polish phase | +| Blog PR | PR in debrief.github.io with post | Triggered by /speckit.pr | + +--- + +## Phase 1: Setup + +**Purpose**: Establish the spec file with metadata and structural sections + +- [x] T001 Create tool spec file with YAML frontmatter (Metadata section) `shared/tools/shape/manipulation/enlarge-shape.1.0.md` +- [x] T002 Write MCP section (description, when-to-use, parameters, returns) `shared/tools/shape/manipulation/enlarge-shape.1.0.md` +- [x] T003 Write Inputs section (schema reference, constraints, defaults) `shared/tools/shape/manipulation/enlarge-shape.1.0.md` +- [x] T004 Write Outputs section (ToolResponse schema, result type path, annotations) `shared/tools/shape/manipulation/enlarge-shape.1.0.md` + +**Checkpoint**: Spec file exists with first 4 sections complete (Metadata, MCP, Inputs, Outputs) + +--- + +## Phase 2: Foundation — Algorithm & Edge Cases + +**Purpose**: Define the core scaling algorithm in pseudocode and document all edge cases. These sections are prerequisites for writing golden examples. + +- [x] T005 Write Algorithm section with centroid computation pseudocode `shared/tools/shape/manipulation/enlarge-shape.1.0.md` +- [x] T006 Write Algorithm section for coordinate scaling per annotation kind (CIRCLE, RECTANGLE, LINE, TEXT, VECTOR) `shared/tools/shape/manipulation/enlarge-shape.1.0.md` +- [x] T007 Write Edge Cases table (10+ scenarios from spec.md) `shared/tools/shape/manipulation/enlarge-shape.1.0.md` + +**Checkpoint**: Algorithm pseudocode complete — can now compute golden example values + +--- + +## Phase 3: User Story 1 — Scale Shape Up from Centroid (Priority: P1) MVP + +**Goal**: Create the basic-polygon golden I/O pair demonstrating core scaling from centroid with default parameters (scale_factor=3.0) + +**Independent Test**: Parse both JSON files, verify output coordinates are 3x farther from centroid than input coordinates + +### Implementation for User Story 1 + +- [x] T008 [US1] Compute centroid and scaled coordinates for basic-polygon example (rectangle, factor 3.0) +- [x] T009 [US1] Create golden input file `shared/tools/shape/manipulation/enlarge-shape.basic-polygon.input.json` +- [x] T010 [US1] Create golden output file `shared/tools/shape/manipulation/enlarge-shape.basic-polygon.output.json` +- [x] T011 [US1] Write inline Examples section in spec with basic-polygon walkthrough `shared/tools/shape/manipulation/enlarge-shape.1.0.md` + +**Checkpoint**: US1 complete — basic scaling from centroid validated with golden pair + +--- + +## Phase 4: User Story 2 — Scale Shape from Custom Origin (Priority: P2) + +**Goal**: Create the custom-origin golden I/O pair demonstrating scaling from an explicit origin point (one vertex fixed) + +**Independent Test**: Parse both JSON files, verify the origin vertex is unchanged and other vertices are repositioned relative to it + +### Implementation for User Story 2 + +- [x] T012 [US2] Compute scaled coordinates for custom-origin example (polygon, factor 2.0, explicit origin at vertex) +- [x] T013 [US2] Create golden input file `shared/tools/shape/manipulation/enlarge-shape.custom-origin.input.json` +- [x] T014 [US2] Create golden output file `shared/tools/shape/manipulation/enlarge-shape.custom-origin.output.json` +- [x] T015 [US2] Add golden file references to Examples section `shared/tools/shape/manipulation/enlarge-shape.1.0.md` + +**Checkpoint**: US2 complete — custom origin scaling validated with golden pair + +--- + +## Phase 5: User Stories 3 & 4 — No-Op and Shrink (Priority: P3) + +**Goal**: Create the noop golden I/O pair (scale_factor=1.0) and add an error response example for negative scale factor + +**Independent Test**: Parse noop JSON files, verify output coordinates exactly match input coordinates + +### Implementation for User Stories 3 & 4 + +- [x] T016 [P] [US3] Create golden input file `shared/tools/shape/manipulation/enlarge-shape.noop.input.json` +- [x] T017 [P] [US3] Create golden output file `shared/tools/shape/manipulation/enlarge-shape.noop.output.json` +- [x] T018 [US3] Add noop golden file references to Examples section `shared/tools/shape/manipulation/enlarge-shape.1.0.md` +- [x] T019 [US4] Add error response example for negative scale factor to Examples section `shared/tools/shape/manipulation/enlarge-shape.1.0.md` + +**Checkpoint**: All golden examples complete — 3 input/output pairs covering centroid, custom origin, and noop + +--- + +## Phase 6: Spec Completion + +**Purpose**: Complete the remaining spec sections (Changelog, References) and validate the full spec + +- [x] T020 Write Changelog section (1.0 initial release) `shared/tools/shape/manipulation/enlarge-shape.1.0.md` +- [x] T021 Write References section (move-shape, annotations.yaml, TEMPLATE.md, Wikipedia) `shared/tools/shape/manipulation/enlarge-shape.1.0.md` +- [x] T022 Validate all 9 sections are present and non-empty in the spec +- [x] T023 Validate all 6 golden JSON files parse correctly (well-formed JSON) +- [x] T024 Verify golden output coordinates match hand-computed values from scaling formula + +**Checkpoint**: Full spec complete and validated — all 9 sections, 3 golden pairs + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Evidence collection, media content, and PR creation + +### Evidence Collection + +- [x] T025 Create evidence directory `specs/057-enlarge-shape/evidence/` +- [x] T026 Capture validation results in `specs/057-enlarge-shape/evidence/test-summary.md` +- [x] T027 Create usage example showing scaling formula walkthrough `specs/057-enlarge-shape/evidence/usage-example.md` +- [x] T028 [P] Create spec validation checklist confirming all 9 sections `specs/057-enlarge-shape/evidence/spec-validation.md` + +### Media Content + +- [x] T029 Create shipped blog post `specs/057-enlarge-shape/media/shipped-post.md` +- [x] T030 [P] Create LinkedIn shipped summary `specs/057-enlarge-shape/media/linkedin-shipped.md` + +### PR Creation + +- [ ] T031 Create PR and publish blog: run /speckit.pr + +**Task T031 must run last. It depends on all evidence and media tasks being complete.** + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: No dependencies — creates spec file with structural sections +- **Phase 2 (Foundation)**: Depends on Phase 1 — algorithm pseudocode needed before golden examples +- **Phase 3 (US1)**: Depends on Phase 2 — needs algorithm to compute example values +- **Phase 4 (US2)**: Depends on Phase 2 — independent of US1 +- **Phase 5 (US3/US4)**: Depends on Phase 2 — independent of US1/US2 +- **Phase 6 (Completion)**: Depends on Phases 3-5 — all golden examples must exist +- **Phase 7 (Polish)**: Depends on Phase 6 — all spec content must be finalized + +### User Story Dependencies + +- **US1 (P1)**: Can start after Phase 2 — no dependencies on other stories +- **US2 (P2)**: Can start after Phase 2 — independent of US1 +- **US3/US4 (P3)**: Can start after Phase 2 — independent of US1/US2 + +### Parallel Opportunities + +- Phases 3, 4, 5 (US1, US2, US3/US4) can all run in **parallel** after Phase 2 completes +- Within Phase 5: T016 and T017 (noop input/output) can run in parallel +- Within Phase 7: T028, T030 can run in parallel with other evidence tasks + +--- + +## Parallel Example: User Stories After Foundation + +```bash +# After Phase 2 (Foundation) completes, launch all three user stories in parallel: +Task: "[US1] Compute centroid and scaled coordinates for basic-polygon" +Task: "[US2] Compute scaled coordinates for custom-origin" +Task: "[US3] Create golden input file for noop" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (spec structure) +2. Complete Phase 2: Foundation (algorithm + edge cases) +3. Complete Phase 3: US1 (basic-polygon golden pair) +4. **STOP and VALIDATE**: Verify spec has algorithm + 1 golden example +5. This alone is a usable spec for implementers + +### Incremental Delivery + +1. Setup + Foundation → Spec structure + algorithm ready +2. Add US1 → basic-polygon golden pair (MVP!) +3. Add US2 → custom-origin golden pair +4. Add US3/US4 → noop + error examples +5. Complete + Polish → Full spec, evidence, PR + +### Single-Session Strategy (recommended for Low complexity) + +This is a Low-complexity spec-only feature. All tasks can be completed in a single session: +1. Write the full spec file (Phases 1-2, ~300 lines Markdown) +2. Compute and create all 3 golden pairs (Phases 3-5) +3. Validate and collect evidence (Phase 6-7) + +--- + +## Notes + +- All deliverables are Markdown + JSON files — no compilation, no test runner +- Golden example coordinates must be hand-computed using the scaling formula from quickstart.md +- Use `move-shape.1.0.md` as the structural reference for section formatting +- Floating-point precision: use 15 significant digits in JSON coordinates +- The spec file is the primary deliverable — golden examples prove correctness +- Run `/speckit.pr` after all tasks complete to create PR with evidence