From 73999a10f41ad1938ee4f654a1190b9f03fba97a Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:32:19 +0000 Subject: [PATCH 01/10] Add scoped visual tokens and qualified offline fonts --- assets/diagram-style/fonts/inter-400.woff2 | Bin 0 -> 23664 bytes assets/diagram-style/fonts/inter-700.woff2 | Bin 0 -> 24356 bytes assets/diagram-style/fonts/inter-OFL.txt | 93 ++++++++++++ assets/diagram-style/fonts/manifest.json | 67 +++++++++ assets/diagram-style/fonts/space-OFL.txt | 93 ++++++++++++ .../fonts/space-grotesk-400.woff2 | Bin 0 -> 13388 bytes .../fonts/space-grotesk-700.woff2 | Bin 0 -> 12840 bytes assets/diagram-style/profile.json | 75 ++++++++++ docs/visual-presentation.md | 39 +++++ tests/test_visual_style.py | 61 ++++++++ verifier/visual_style.py | 136 ++++++++++++++++++ 11 files changed, 564 insertions(+) create mode 100644 assets/diagram-style/fonts/inter-400.woff2 create mode 100644 assets/diagram-style/fonts/inter-700.woff2 create mode 100644 assets/diagram-style/fonts/inter-OFL.txt create mode 100644 assets/diagram-style/fonts/manifest.json create mode 100644 assets/diagram-style/fonts/space-OFL.txt create mode 100644 assets/diagram-style/fonts/space-grotesk-400.woff2 create mode 100644 assets/diagram-style/fonts/space-grotesk-700.woff2 create mode 100644 assets/diagram-style/profile.json create mode 100644 docs/visual-presentation.md create mode 100644 tests/test_visual_style.py create mode 100644 verifier/visual_style.py diff --git a/assets/diagram-style/fonts/inter-400.woff2 b/assets/diagram-style/fonts/inter-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f15b025d666269fb07daf0263e54231481e4289c GIT binary patch literal 23664 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 literal 0 HcmV?d00001 diff --git a/assets/diagram-style/fonts/inter-700.woff2 b/assets/diagram-style/fonts/inter-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a68fb101cd9224aa7bb6ca80356b5132c40c6f3f GIT binary patch literal 24356 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 literal 0 HcmV?d00001 diff --git a/assets/diagram-style/fonts/space-grotesk-700.woff2 b/assets/diagram-style/fonts/space-grotesk-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..44604a0bdd675b19330a3a2ffe87d74aed261efb GIT binary patch literal 12840 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 GLCRiVB40oN literal 0 HcmV?d00001 diff --git a/assets/diagram-style/profile.json b/assets/diagram-style/profile.json new file mode 100644 index 0000000..abd17bc --- /dev/null +++ b/assets/diagram-style/profile.json @@ -0,0 +1,75 @@ +{ + "version": 1, + "name": "Graphite and signal", + "direction_status": "selected_direction", + "fonts": { + "title": "Inter", + "body": "Space Grotesk" + }, + "body_variants": { + "status": "provisional", + "values": [ + "paper", + "white", + "tint" + ] + }, + "numeric_proposal": { + "status": "slice_specific_proposal", + "title_size": 36, + "body_size": 22, + "stroke_display_px": 2 + }, + "limits": { + "glyphs": "printable_ascii_plus_middle_dot", + "color_semantics": "none" + }, + "palette": { + "paper": "#eeeeea", + "ink": "#25282d", + "light": "#dededb", + "middle": "#b6b7b5", + "families": { + "blue": { + "shade": "#27456f", + "base": "#32588e", + "tint": "#d2dae6" + }, + "red": { + "shade": "#7f362f", + "base": "#a3453c", + "tint": "#ebd6d4" + }, + "amber": { + "shade": "#825d19", + "base": "#a77720", + "tint": "#ece1ce" + }, + "green": { + "shade": "#315e43", + "base": "#3f7956", + "tint": "#d5e2da" + }, + "cyan": { + "shade": "#2e6371", + "base": "#3b7f91", + "tint": "#d4e3e7" + }, + "violet": { + "shade": "#58436f", + "base": "#71568e", + "tint": "#e0dae6" + }, + "tangerine": { + "shade": "#894b25", + "base": "#b06030", + "tint": "#eedcd1" + }, + "berry": { + "shade": "#753c53", + "base": "#964d6b", + "tint": "#e8d8de" + } + } + } +} diff --git a/docs/visual-presentation.md b/docs/visual-presentation.md new file mode 100644 index 0000000..790de36 --- /dev/null +++ b/docs/visual-presentation.md @@ -0,0 +1,39 @@ +# Opt-in visual presentation + +This experimental slice renders one checked documentary function tree from the existing native-bound `CSE-VIEWS/1.0` contract. It adds presentation, not engineering meaning or behavioral verification. Existing model checking and JSON context commands keep their defaults. + +## Generate and revise + +```sh +python3 scripts/sysml_present.py examples/sysml/views/sample-handling.sysml --view FT --output work/tree-paper --body paper --target-width 1200 +``` + +Use the CLI help for the current flags. The output directory must be new. The native parser and view checks must pass before any diagram is emitted. A function tree is an explicit documentary decomposition; it is not an activity sequence, allocation, native port network or simulation. The full native element identity and exact parent link accompany each displayed action name. + +Edit the authoritative SysML and regenerate into another new directory. Do not patch exported SVG to change the engineering model. The output includes editable SVG text, an offline HTML reader, a semantic ledger, presentation parameters and a manifest. An actual graphical editor edit/save/reopen is a separate qualification. + +## Selected direction and provisional parameters + +The bundled profile retains Graphite and signal palette03 and Inter titles with Space Grotesk body text. Its eight color families and four neutrals are reusable presentation values. They establish no global relation, status, risk, type or acceptance semantics. Orange on violet is an optional scoped presentation pairing, not a semantic classification. + +White, paper and faint-tint bodies remain explicit provisional alternatives. The proposed tint is 6% violet base over white, rounded per sRGB channel; it is not a ninth palette family. The function-tree defaults of 36-unit titles, 22-unit labels and 2 display-pixel lines are named slice-specific proposals. They do not override formal notation, source geometry or another view family's requirements. + +This slice refuses unsupported characters outside printable ASCII plus middle dot. The declared font files have broader ranges, but those ranges are not claimed as exercised glyph coverage. No forced tracking, text length, stretching or text-fitting transforms are applied. The target must retain essential label size; use a larger output/native scrolling for a wider model. + +## Reusable boundaries + +`verifier.visual_style` validates profile data, embeds exact font bytes/notices, computes opaque-field contrast and converts a proposed display stroke width to native coordinates. `verifier.visual_projection` consumes the published parsed inventory after existing checks, retains model identities and parentage, and emits ordinary vector boxes, exact text and separate parent paths. Shared routes and crossings do not become junctions. + +A narrow first slice does not implement physical glyph semantics, RF/data/material hatches, circuit connectivity or formal port notation. Source-qualified line patterns, contact side/order, arrows, texture classes and values stay with their domain adapters. The broader draft-profile and crispness studies are references for later qualification, not permission to infer those relationships from styling. No private worked reference art is shipped as a production renderer. + +## Checks and limits + +Run `python3 scripts/check.py --require-sysml` with the existing pinned parser installed and Java21 available. New focused suites cover malformed profiles, invalid native bindings, hierarchy defects, source preservation, deterministic output and edit/regeneration. + +Contrast uses opaque sRGB fields and unrounded ratios; this slice requires at least4.5:1 for its text fields. This is a bounded numerical check, not a complete accessibility or readability verdict. [W3C contrast guidance](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) explains why actual size and antialiasing also matter. Inspect native and target-width pixels, loaded font identities, clipping, overlap and endpoints in the actual browser. Display widths are CSS pixels, not calibrated print dimensions. + +The output manifest identifies supplied model bytes, profile, producer and font assets. Hashes identify inputs, not genuine engineering acceptance, authenticated decisions or a protected runner. Parse success and documentary structure do not establish semantic truth or completeness. Parent visual acceptance, installed-client activation, other diagram families, calibrated print and reader-comprehension claims remain separate. + +## Fonts and rights + +The four unchanged Latin WOFF2 faces come from `@fontsource/inter@5.3.0` and `@fontsource/space-grotesk@5.3.0`. Exact files, declared ranges, hashes and source-package identities are in `assets/diagram-style/fonts/manifest.json`. Original OFL notices are retained alongside the files and embedded in standalone font CSS. No font modifications or independent binary-revision decoding are claimed. The repository MIT license does not replace these notices. diff --git a/tests/test_visual_style.py b/tests/test_visual_style.py new file mode 100644 index 0000000..d60a088 --- /dev/null +++ b/tests/test_visual_style.py @@ -0,0 +1,61 @@ +import json +from pathlib import Path +import sys +import tempfile +import unittest +from copy import deepcopy +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from verifier.visual_style import load_profile, contrast, presentation_style, require_supported_text, font_css + +class VisualStyleTests(unittest.TestCase): + def test_palette_and_provisional_scope(self): + p = load_profile() + self.assertEqual(len(p["palette"]["families"]), 8) + self.assertEqual(p["body_variants"]["status"], "provisional") + self.assertEqual(p["limits"]["color_semantics"], "none") + for body, expected in (("paper", "#eeeeea"), ("white", "#ffffff"), ("tint", "#f6f5f8")): + s = presentation_style(body=body) + self.assertEqual(s["fill"], expected) + self.assertGreaterEqual(s["contrast"]["body_text"], 4.5) + self.assertGreaterEqual(s["contrast"]["accent_text"], 4.5) + + def test_absolute_contrast_oracles(self): + self.assertAlmostEqual(contrast("#000000", "#ffffff"), 21) + self.assertEqual(contrast("#123456", "#123456"), 1) + with self.assertRaises(ValueError): contrast("transparent", "#ffffff") + + def test_effective_stroke_and_small_target_refusal(self): + s = presentation_style(target_width=900, canvas_width=1200) + self.assertAlmostEqual(s["stroke_width"] * 900 / 1200, 2) + with self.assertRaises(ValueError): presentation_style(target_width=400, canvas_width=1200) + for value in (True, float("inf"), float("nan"), 0): + with self.subTest(value=value), self.assertRaises(ValueError): + presentation_style(target_width=value) + with self.assertRaises(ValueError): presentation_style(body="approved-default") + + def test_profile_refuses_unknown_or_semantic_changes(self): + for mutate in ("unknown", "numeric", "status", "color", "contrast"): + p = deepcopy(load_profile()) + if mutate == "unknown": p["execute"] = "anything" + elif mutate == "numeric": p["numeric_proposal"]["body_size"] = True + elif mutate == "status": p["body_variants"]["status"] = "accepted" + elif mutate == "color": p["palette"]["ink"] = "url(file:///private)" + else: p["palette"]["ink"] = "#eeeeea" + with tempfile.TemporaryDirectory(dir=ROOT / ".tmp") as folder: + path = Path(folder) / "profile.json"; path.write_text(json.dumps(p)) + with self.assertRaises(ValueError): load_profile(path) + + def test_font_bytes_and_explicit_coverage(self): + css = font_css() + self.assertEqual(css.count("@font-face"), 4) + self.assertEqual(css.count("data:font/woff2;base64,"), 4) + self.assertNotIn("url(http", css) + self.assertIn("SIL OPEN FONT LICENSE", css) + require_supported_text("ProcessSample · F0") + for value in ("theta \u03b8", "\n", ""): + with self.assertRaises(ValueError): require_supported_text(value) + +if __name__ == "__main__": + (ROOT / ".tmp").mkdir(exist_ok=True) + unittest.main() diff --git a/verifier/visual_style.py b/verifier/visual_style.py new file mode 100644 index 0000000..497ff9d --- /dev/null +++ b/verifier/visual_style.py @@ -0,0 +1,136 @@ +"""Opt-in presentation values; never model meaning or engineering acceptance.""" +import base64 +from copy import deepcopy +from hashlib import sha256 +import json +import math +from pathlib import Path +import re + +ASSETS = Path(__file__).resolve().parents[1] / "assets/diagram-style" +HEX = re.compile(r"#[0-9a-fA-F]{6}\Z") + + +def _unique(pairs): + out = {} + for key, value in pairs: + if key in out: + raise ValueError(f"Duplicate profile key: {key}") + out[key] = value + return out + + +def _color(value): + if not isinstance(value, str) or not HEX.fullmatch(value): + raise ValueError("Colors must be opaque #RRGGBB values") + return value + + +def contrast(foreground, background): + """sRGB relative luminance ratio for opaque fields; not a readability verdict.""" + def luminance(color): + channels = [int(_color(color)[i:i+2], 16) / 255 for i in (1, 3, 5)] + channels = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in channels] + return sum(v * weight for v, weight in zip(channels, (0.2126, 0.7152, 0.0722))) + a, b = sorted((luminance(foreground), luminance(background))) + return (b + 0.05) / (a + 0.05) + + +def load_profile(path=None): + data = json.loads(Path(path or ASSETS / "profile.json").read_text(), object_pairs_hook=_unique, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError(f"Nonfinite {value}"))) + expected = {"version", "name", "direction_status", "palette", "fonts", "body_variants", "numeric_proposal", "limits"} + if not isinstance(data, dict) or set(data) != expected or type(data["version"]) is not int or data["version"] != 1: + raise ValueError("Expected visual profile version 1 and exact documented fields") + palette = data["palette"] + if not isinstance(palette, dict) or set(palette) != {"paper", "ink", "light", "middle", "families"}: + raise ValueError("Malformed palette") + for key in ("paper", "ink", "light", "middle"): + _color(palette[key]) + families = palette["families"] + if not isinstance(families, dict) or set(families) != {"blue", "red", "amber", "green", "cyan", "violet", "tangerine", "berry"}: + raise ValueError("Expected eight named palette families") + for colors in families.values(): + if not isinstance(colors, dict) or set(colors) != {"shade", "base", "tint"}: + raise ValueError("Family needs shade, base and tint") + for value in colors.values(): + _color(value) + if data["fonts"] != {"title": "Inter", "body": "Space Grotesk"}: + raise ValueError("This slice qualifies only the bundled Inter / Space Grotesk faces") + if data["direction_status"] != "selected_direction" or data["body_variants"] != {"status": "provisional", "values": ["paper", "white", "tint"]}: + raise ValueError("Preserve selected direction and provisional body-variant distinction") + numeric = data["numeric_proposal"] + if not isinstance(numeric, dict) or set(numeric) != {"status", "title_size", "body_size", "stroke_display_px"} or numeric["status"] != "slice_specific_proposal": + raise ValueError("Numeric defaults must remain a slice-specific proposal") + for key, low, high in (("title_size", 32, 48), ("body_size", 20, 24), ("stroke_display_px", 1.5, 3)): + v = numeric[key] + if type(v) not in (int, float) or not math.isfinite(v) or not low <= v <= high: + raise ValueError(f"Invalid proposed {key}") + if data["limits"] != {"glyphs": "printable_ascii_plus_middle_dot", "color_semantics": "none"}: + raise ValueError("Unsupported glyph or semantic color scope") + for fill in (palette["paper"], "#ffffff", _tint(palette["families"]["violet"]["base"])): + if contrast(palette["ink"], fill) < 4.5: + raise ValueError("Insufficient text contrast for a body variant") + if contrast(palette["families"]["tangerine"]["shade"], palette["families"]["violet"]["tint"]) < 4.5: + raise ValueError("Insufficient orange-on-violet text contrast") + return deepcopy(data) + + +def _tint(color): + return "#" + "".join(f"{math.floor(0.94 * 255 + 0.06 * int(color[i:i+2], 16) + 0.5):02x}" for i in (1, 3, 5)) + + +def require_supported_text(text): + if not isinstance(text, str) or not text or any(not (32 <= ord(c) <= 126 or c == "\u00b7") for c in text): + raise ValueError("This slice qualifies printable ASCII plus middle dot only; other glyphs need qualification") + + +def font_css(): + manifest = json.loads((ASSETS / "fonts/manifest.json").read_text()) + css = [] + included_notices = set() + for item in manifest["faces"]: + raw = (ASSETS / "fonts" / item["file"]).read_bytes() + if sha256(raw).hexdigest() != item["sha256"]: + raise ValueError("Bundled font integrity mismatch: " + item["file"]) + notice = (ASSETS / "fonts" / item["notice"]).read_bytes() + if sha256(notice).hexdigest() != item["notice_sha256"]: + raise ValueError("Bundled font notice integrity mismatch") + if item["notice"] not in included_notices: + notice_text = notice.decode("utf-8") + if "*/" in notice_text or " Date: Sun, 18 Oct 2026 17:32:19 +0000 Subject: [PATCH 02/10] Add checked native function-tree presentation CLI --- README.md | 4 + scripts/check.py | 2 +- scripts/sysml_present.py | 35 +++++++ tests/test_visual_projection.py | 150 +++++++++++++++++++++++++++ verifier/visual_projection.py | 176 ++++++++++++++++++++++++++++++++ 5 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 scripts/sysml_present.py create mode 100644 tests/test_visual_projection.py create mode 100644 verifier/visual_projection.py diff --git a/README.md b/README.md index 376a5a4..4e31dba 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,7 @@ The [SysML-first plan](docs/sysml-v2-model-first-plan.md) and [implemented alpha The project uses deterministic program results for computable claims. Broader validation still requires evidence and engineering judgment. JSON is a transport format, not a token-efficiency claim; measure context and repair costs before making such claims. See [public references](docs/references.md) and [contribution guidance](CONTRIBUTING.md). External publications and tools remain under their own terms; none is bundled here. + +## Experimental visual presentation + +An opt-in checked documentary function-tree renderer adds palette03, embedded Inter/Space Grotesk fonts and explicit provisional body variants. See [visual presentation](docs/visual-presentation.md) for generation, editable sources, checks and limits. Existing checking defaults remain unchanged. diff --git a/scripts/check.py b/scripts/check.py index 884b037..291a9c6 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -43,7 +43,7 @@ def main(): suites = ("test_develop_conops.py", "test_scenario_split.py", "test_concept.py", "test_verifier.py", "test_language.py", "test_language_oracle.py", "test_influence.py", "test_events.py", "test_public_language_cases.py", "test_sysml.py", "test_sysml_views.py", - "test_sysml_public.py", "test_skill_runtime.py", "test_engineering_views.py") + "test_sysml_public.py", "test_skill_runtime.py", "test_engineering_views.py", "test_visual_style.py", "test_visual_projection.py") for suite in suites: result = subprocess.run([sys.executable, "-B", str(ROOT / "tests" / suite)], cwd=ROOT, env=environment, text=True, diff --git a/scripts/sysml_present.py b/scripts/sysml_present.py new file mode 100644 index 0000000..c90f34a --- /dev/null +++ b/scripts/sysml_present.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Create an offline documentary function-tree presentation from native SysML.""" +import argparse +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from verifier.visual_projection import PresentationError, write_presentation +from verifier.visual_style import load_profile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('model', nargs='+') + parser.add_argument('--view', required=True) + parser.add_argument('--output', required=True, help='new output directory (must not exist)') + parser.add_argument('--profile', help='validated presentation style JSON') + parser.add_argument('--body', choices=('paper', 'white', 'tint'), default='paper') + parser.add_argument('--target-width', type=int, default=1200) + args = parser.parse_args() + try: + report = write_presentation(args.model, args.output, view_id=args.view, + profile=load_profile(args.profile), body=args.body, + target_width=args.target_width) + except PresentationError as exc: + report = exc.report + except (OSError, ValueError, KeyError) as exc: + report = {'status': 'error', 'findings': [{'code': 'presentation', 'detail': str(exc)}]} + print(json.dumps(report, indent=2, sort_keys=True, ensure_ascii=False)) + return 0 if report['status'] == 'pass' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_visual_projection.py b/tests/test_visual_projection.py new file mode 100644 index 0000000..96c2bd1 --- /dev/null +++ b/tests/test_visual_projection.py @@ -0,0 +1,150 @@ +"""Synthetic documentary projection, refusal, offline and native regeneration tests.""" +from copy import deepcopy +from hashlib import sha256 +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from verifier.sysml import CLASSES, JAR +from verifier.visual_projection import PresentationError, project_function_tree, render_function_tree, write_presentation + +MODEL = ROOT / 'examples/sysml/views/sample-handling.sysml' + + +def fixture(): + nodes = [{'id': 'F0', 'element': 'Example::Process', 'level': 0, 'parent': None}, + {'id': 'F1', 'element': 'Example::Read', 'level': 1, 'parent': 'F0'}, + {'id': 'F2', 'element': 'Example::Store', 'level': 1, 'parent': 'F0'}] + inventory = {'actions': [{'qualified_name': n['element'], 'name': name} for n, name in + zip(nodes, ['Process item', 'Read & "quote"', 'Store item'])], 'parts': [], + 'documents': [{'name': 'cseViews', 'owner_kind': 'Package', 'body': ''}]} + return nodes, inventory + + +def project(nodes, inventory): + inventory['documents'][0]['body'] = 'CSE-VIEWS/1.0\n' + json.dumps({'views': [{'id': 'FT', 'kind': 'function_tree', 'nodes': nodes}]}) + return project_function_tree(inventory, 'FT') + + +class ProjectionTests(unittest.TestCase): + def test_exact_identity_text_xml_and_determinism(self): + nodes, inventory = fixture() + expected = deepcopy(nodes) + projection = project(nodes, inventory) + self.assertEqual(nodes, expected) + for n, original in zip(projection['nodes'], expected): + self.assertEqual({k: n[k] for k in original}, original) + svg, ledger = render_function_tree(projection) + self.assertEqual((svg, ledger), render_function_tree(projection)) + root = ET.fromstring(svg) + texts = [item.text for item in root.iter('{http://www.w3.org/2000/svg}text')] + self.assertIn('Read & "quote"', texts) + self.assertNotIn('', svg) + self.assertNotIn('marker-end', svg) + groups = [item for item in root.iter('{http://www.w3.org/2000/svg}g')] + self.assertEqual([g.attrib['data-element'] for g in groups], [n['element'] for n in nodes]) + self.assertEqual(len(list(root.iter('{http://www.w3.org/2000/svg}path'))), len(nodes)-1) + for a, b in zip(ledger['geometry'], ledger['geometry'][1:]): + self.assertLessEqual(a['y'] + a['height'], b['y']) + + def test_invalid_structure_bindings_and_requested_views_refused(self): + for defect in ('cycle', 'orphan', 'level', 'binding', 'duplicate', 'missing', 'kind'): + nodes, inventory = fixture() + if defect == 'cycle': + nodes[1]['parent'] = 'F2'; nodes[2]['parent'] = 'F1' + elif defect == 'orphan': nodes[1]['parent'] = 'MISSING' + elif defect == 'level': nodes[1]['level'] = 4 + elif defect == 'binding': nodes[1]['element'] = 'Absent' + elif defect == 'duplicate': nodes.append(deepcopy(nodes[1])) + with self.subTest(defect=defect): + if defect in ('missing', 'kind'): + project(nodes, inventory) + if defect == 'missing': inventory['documents'] = [] + else: inventory['documents'][0]['body'] = 'CSE-VIEWS/1.0\n' + json.dumps({'views': [{'id': 'FT', 'kind': 'unrecognized'}]}) + with self.assertRaises(PresentationError): project_function_tree(inventory, 'FT') + else: + with self.assertRaises(PresentationError): project(nodes, inventory) + + def test_render_revalidates_direct_projection_and_bounds(self): + nodes, inventory = fixture() + projection = project(nodes, inventory) + projection['nodes'][1]['parent'] = 'Absent' + with self.assertRaises(PresentationError): render_function_tree(projection) + projection = project(nodes, inventory) + for width in (0, float('nan'), True, 9000): + with self.subTest(width=width), self.assertRaises(ValueError): + render_function_tree(projection, target_width=width) + projection['nodes'][0]['text'] = 'Unsupported \u2603' + with self.assertRaises(ValueError): render_function_tree(projection) + + def test_failed_native_acquisition_creates_no_output(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: + output = Path(temp) / 'output' + for status in ('fail', 'unknown', 'error', 'unsupported'): + report = {'status': status, 'findings': [{'code': 'input_or_parser', 'detail': 'SysML pilot not installed'}]} + with patch('verifier.visual_projection.check_sysml', return_value=report): + self.assertEqual(write_presentation([MODEL], output, view_id='FT'), report) + self.assertFalse(output.exists()) + + +@unittest.skipUnless(JAR.is_file() and (CLASSES / 'SysmlBridge.class').is_file(), 'Pinned native parser unavailable') +class NativePresentationTests(unittest.TestCase): + def test_native_offline_hashes_sources_exclusive_output_and_regeneration(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: + temp = Path(temp) + source = temp / 'model.sysml' + source.write_bytes(MODEL.read_bytes()) + before = source.read_bytes() + report = write_presentation(source, temp / 'one', view_id='FT') + self.assertEqual(report['status'], 'pass', report) + self.assertEqual(source.read_bytes(), before) + write_presentation(source, temp / 'two', view_id='FT') + for file in (temp / 'one').rglob('*'): + if file.is_file(): self.assertEqual(file.read_bytes(), (temp / 'two' / file.relative_to(temp / 'one')).read_bytes()) + manifest = json.loads((temp / 'one/manifest.json').read_text()) + for name, digest in manifest['files'].items(): + self.assertEqual(sha256((temp / 'one' / name).read_bytes()).hexdigest(), digest) + self.assertEqual((temp / 'one/sources/model-1.sysml').read_bytes(), before) + svg = (temp / 'one/view.svg').read_text() + html = (temp / 'one/index.html').read_text() + self.assertIn(svg, html) + self.assertIn('data:font/', html) + self.assertNotIn(' 2000: + raise ValueError("Presentation supports native names up to 2000 characters") + nodes.append(node) + if len(nodes) > 128 or max(node["level"] for node in nodes) > 16: + raise ValueError("Presentation supports at most 128 nodes and 16 decomposition levels") + return {"version": VERSION, "id": view_id, "kind": "function_tree", "claim_scope": CLAIM, + "nodes": nodes, "view_checks": checks} + + +def render_function_tree(projection, *, profile=None, body="paper", target_width=1200): + """Render an arrow-free indented tree; preserve labels as literal XML text.""" + if type(target_width) not in (int, float) or not math.isfinite(target_width) or not 320 <= target_width <= 8192: + raise ValueError("Target width must be between 320 and 8192 pixels") + # Recheck the public structural contract even when called without project(). + if projection.get("kind") != "function_tree" or projection.get("version") != VERSION: + raise ValueError("Expected bounded function-tree presentation version") + raw_nodes = [{key: n[key] for key in ("id", "element", "level", "parent")} for n in projection["nodes"]] + inventory = {"documents": [{"name": "cseViews", "owner_kind": "Package", "body": + "CSE-VIEWS/1.0\n" + json.dumps({"views": [{"id": projection["id"], "kind": "function_tree", "nodes": raw_nodes}]})}], + "parts": [], "actions": [{"qualified_name": n["element"], "name": n["text"]} for n in projection["nodes"]]} + checked = project_function_tree(inventory, projection["id"]) + nodes = checked["nodes"] + require_supported_text(projection["id"]) + for node in nodes: + require_supported_text(node["text"]) + by_id = {node["id"]: node for node in nodes} + children = {node["id"]: [] for node in nodes} + for node in nodes: + if node["parent"] is not None: + children[node["parent"]].append(node["id"]) + root = next(node["id"] for node in nodes if node["parent"] is None) + order, pending = [], [root] + while pending: + key = pending.pop() + order.append(key) + pending.extend(reversed(children[key])) + # Each label has its own row. Horizontal scrolling preserves readable type. + # Conservative per-codepoint width prevents clipping without a font renderer dependency. + width = max(1200, 96 + (len(projection["id"]) + 25) * 28, max(104 + n["level"] * 48 + max(len(n["text"]), len(n["id"])) * 30 for n in nodes)) + height = 160 + 104 * len(nodes) + style = presentation_style(profile, body=body, target_width=target_width, canvas_width=width) + positions = {key: (48 + by_id[key]["level"] * 48, 112 + i * 104) for i, key in enumerate(order)} + svg = [f'', + f'{escape(projection["id"])} - function decomposition', + f'{escape(CLAIM)}', + '', + f'', + f'{escape(projection["id"])} - Function decomposition', + f'Documentary parent links · no behavioral or engineering acceptance claim'] + for key in order: + node = by_id[key] + if node['parent'] is not None: + px, py = positions[node['parent']] + x, y = positions[key] + svg.append(f'') + geometry = [] + for key in order: + node = by_id[key] + x, y = positions[key] + box_width = width - x - 48 + attrs = ' '.join(f'data-{k}="{escape(str(node[k]), quote=True)}"' for k in ("id", "element", "level", "parent")) + svg.extend([f'', f'', + f'{key} · level {node["level"]}', + f'{escape(node["text"])}', '']) + geometry.append({"id": key, "x": x, "y": y, "width": box_width, "height": 72}) + svg.append('') + ledger = {"version": VERSION, "body": body, "target_width": target_width, "canvas_width": width, + "canvas_height": height, "claim_scope": CLAIM, "geometry": geometry, "style": style} + return '\n'.join(svg) + '\n', ledger + + +def write_presentation(model_paths, output, *, view_id, profile=None, body="paper", target_width=1200): + """Acquire a native inventory and write a new, independently editable bundle.""" + output = Path(output) + if output.exists() or output.is_symlink(): + raise FileExistsError(f"Presentation output already exists: {output}") + paths = [Path(model_paths)] if isinstance(model_paths, (str, Path)) else [Path(p) for p in model_paths] + report = check_sysml(paths, syntax_only=True, view_profile={"version": 1, "views": [{"id": view_id, "kind": "function_tree"}]}) + if report["status"] != "pass": + return report + projection = project_function_tree(report["inventory"], view_id) + validated_profile = load_profile(profile) if profile is None or isinstance(profile, (str, Path)) else deepcopy(profile) + svg, presentation = render_function_tree(projection, profile=validated_profile, body=body, target_width=target_width) + # Capture source bytes and verify parser pins before creating any output. + sources = {} + for path in paths: + raw = path.read_bytes() + resolved = path.resolve() + matched = next((entry for entry in report["model_files"] if entry["path"] == str(resolved) or + (Path(__file__).resolve().parents[1] / entry["path"]).resolve() == resolved), None) + if matched is None or matched["sha256"] != _digest(raw): + raise ValueError("Model source changed during presentation acquisition") + sources[resolved.as_posix()] = raw + files = {"view.svg": svg.encode(), "semantic-ledger.json": _json(projection).encode(), + "presentation.json": _json(presentation).encode()} + # Plain offline HTML: exact SVG, embedded fonts, scroll and native browser zoom. + html = '\nFunction decomposition

' + escape(CLAIM) + '

Scroll to inspect the diagram; use browser zoom to enlarge.

' + svg + '
\n' + files["index.html"] = html.encode() + font_manifest_raw = (ASSETS / 'fonts/manifest.json').read_bytes() + font_manifest = json.loads(font_manifest_raw) + files['font-notices/manifest.json'] = font_manifest_raw + for face in font_manifest['faces']: + files['font-notices/' + face['notice']] = (ASSETS / 'fonts' / face['notice']).read_bytes() + source_manifest = [] + for i, (path, raw) in enumerate(sorted(sources.items())): + companion = f"sources/model-{i+1}.sysml" + files[companion] = raw + source_manifest.append({"file": companion, "name": Path(path).name, "sha256": _digest(raw)}) + manifest = {"version": VERSION, "view_id": view_id, "claim_scope": CLAIM, + "model_sha256": report["model_sha256"], "parser": report["parser"], + "checker_version": report["checker_version"], "sources": source_manifest, + "style_sha256": _digest(_json(validated_profile).encode()), + "font_manifest_sha256": _digest(font_manifest_raw), + "implementation_sha256": {str(Path('verifier') / name): _digest((Path(__file__).parent / name).read_bytes()) + for name in ('visual_projection.py', 'visual_style.py', 'engineering_views.py', 'sysml.py')}, + "files": {name: _digest(raw) for name, raw in sorted(files.items())}, + "editable_source": "sources/*.sysml are exact source companions; edit and regenerate with sysml_present.py. No editor roundtrip claim."} + files['manifest.json'] = _json(manifest).encode() + output.mkdir() # exclusive: never overwrite an existing presentation + try: + (output / 'sources').mkdir() + (output / 'font-notices').mkdir() + for name, raw in sorted(files.items()): + (output / name).write_bytes(raw) + except OSError: + shutil.rmtree(output) + raise + return {"status": "pass", "claim_scope": CLAIM, "manifest": manifest} From 844a33796df3ea1fee65e24f127d0c459c4b35a1 Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:37:22 +0000 Subject: [PATCH 03/10] Fix custom profiles and align selected presentation roles --- tests/test_visual_projection.py | 43 +++++++++++++++++++++++++++++++++ tests/test_visual_style.py | 13 ++++++++++ verifier/visual_projection.py | 8 +++--- verifier/visual_style.py | 15 +++--------- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/tests/test_visual_projection.py b/tests/test_visual_projection.py index 96c2bd1..9f1b40b 100644 --- a/tests/test_visual_projection.py +++ b/tests/test_visual_projection.py @@ -54,6 +54,24 @@ def test_exact_identity_text_xml_and_determinism(self): for a, b in zip(ledger['geometry'], ledger['geometry'][1:]): self.assertLessEqual(a['y'] + a['height'], b['y']) + def test_parsed_svg_preserves_title_body_roles_and_square_containers(self): + nodes, inventory = fixture() + root = ET.fromstring(render_function_tree(project(nodes, inventory))[0]) + namespace = {'svg': 'http://www.w3.org/2000/svg'} + title, annotation = root.findall('svg:text', namespace) + self.assertEqual(title.attrib['font-family'], 'Inter') + self.assertEqual(annotation.attrib['font-family'], 'Space Grotesk') + groups = root.findall('svg:g', namespace) + self.assertEqual(len(groups), len(nodes)) + for group in groups: + metadata, native_label = group.findall('svg:text', namespace) + self.assertIn('level', metadata.text) + self.assertEqual(metadata.attrib['font-family'], 'Space Grotesk') + self.assertEqual(native_label.attrib['font-family'], 'Space Grotesk') + rectangle = group.find('svg:rect', namespace) + self.assertNotIn('rx', rectangle.attrib) + self.assertNotIn('ry', rectangle.attrib) + def test_invalid_structure_bindings_and_requested_views_refused(self): for defect in ('cycle', 'orphan', 'level', 'binding', 'duplicate', 'missing', 'kind'): nodes, inventory = fixture() @@ -131,6 +149,31 @@ def test_native_offline_hashes_sources_exclusive_output_and_regeneration(self): self.assertNotEqual(failed['status'], 'pass') self.assertFalse((temp / 'bad').exists()) + def test_native_cli_accepts_nondefault_profile_and_preserves_provisional_status(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: + temp = Path(temp) + profile = json.loads((ROOT / 'assets/diagram-style/profile.json').read_text()) + profile['numeric_proposal']['body_size'] = 23 + profile_path = temp / 'profile.json' + profile_path.write_text(json.dumps(profile)) + output = temp / 'custom-profile' + result = subprocess.run([ + sys.executable, str(ROOT / 'scripts/sysml_present.py'), str(MODEL), + '--view', 'FT', '--output', str(output), '--profile', str(profile_path), + '--body', 'tint'], cwd=ROOT, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(json.loads(result.stdout)['status'], 'pass') + presentation = json.loads((output / 'presentation.json').read_text()) + self.assertEqual(presentation['style']['body_size'], 23) + self.assertEqual(presentation['style']['body_status'], 'provisional') + self.assertEqual(presentation['body'], 'tint') + tree = ET.parse(output / 'view.svg') + labels = tree.findall('.//{http://www.w3.org/2000/svg}g/{http://www.w3.org/2000/svg}text') + self.assertTrue(labels) + self.assertTrue(all(label.attrib['font-size'] == '23' for label in labels)) + self.assertEqual(profile_path.read_text(), json.dumps(profile)) + def test_cli_invalid_view_and_missing_parser_diagnostics(self): (ROOT / '.tmp').mkdir(exist_ok=True) with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: diff --git a/tests/test_visual_style.py b/tests/test_visual_style.py index d60a088..1a92c9e 100644 --- a/tests/test_visual_style.py +++ b/tests/test_visual_style.py @@ -46,6 +46,19 @@ def test_profile_refuses_unknown_or_semantic_changes(self): path = Path(folder) / "profile.json"; path.write_text(json.dumps(p)) with self.assertRaises(ValueError): load_profile(path) + def test_nondefault_profile_dict_and_file_share_strict_validation(self): + p = load_profile() + p["numeric_proposal"]["body_size"] = 23 + expected = deepcopy(p) + with tempfile.TemporaryDirectory(dir=ROOT / ".tmp") as folder: + path = Path(folder) / "profile.json" + path.write_text(json.dumps(p)) + self.assertEqual(presentation_style(load_profile(path))["body_size"], 23) + self.assertEqual(presentation_style(path)["body_size"], 23) + self.assertEqual(p, expected) + p["body_variants"]["status"] = "accepted" + with self.assertRaises(ValueError): presentation_style(p) + def test_font_bytes_and_explicit_coverage(self): css = font_css() self.assertEqual(css.count("@font-face"), 4) diff --git a/verifier/visual_projection.py b/verifier/visual_projection.py index 206b655..2657f0d 100644 --- a/verifier/visual_projection.py +++ b/verifier/visual_projection.py @@ -94,7 +94,7 @@ def render_function_tree(projection, *, profile=None, body="paper", target_width '', f'', f'{escape(projection["id"])} - Function decomposition', - f'Documentary parent links · no behavioral or engineering acceptance claim'] + f'Documentary parent links · no behavioral or engineering acceptance claim'] for key in order: node = by_id[key] if node['parent'] is not None: @@ -107,8 +107,8 @@ def render_function_tree(projection, *, profile=None, body="paper", target_width x, y = positions[key] box_width = width - x - 48 attrs = ' '.join(f'data-{k}="{escape(str(node[k]), quote=True)}"' for k in ("id", "element", "level", "parent")) - svg.extend([f'', f'', - f'{key} · level {node["level"]}', + svg.extend([f'', f'', + f'{key} · level {node["level"]}', f'{escape(node["text"])}', '']) geometry.append({"id": key, "x": x, "y": y, "width": box_width, "height": 72}) svg.append('') @@ -142,7 +142,7 @@ def write_presentation(model_paths, output, *, view_id, profile=None, body="pape files = {"view.svg": svg.encode(), "semantic-ledger.json": _json(projection).encode(), "presentation.json": _json(presentation).encode()} # Plain offline HTML: exact SVG, embedded fonts, scroll and native browser zoom. - html = '\nFunction decomposition

' + escape(CLAIM) + '

Scroll to inspect the diagram; use browser zoom to enlarge.

' + svg + '
\n' + html = '\nFunction decomposition

' + escape(CLAIM) + '

Scroll to inspect the diagram; use browser zoom to enlarge.

' + svg + '
\n' files["index.html"] = html.encode() font_manifest_raw = (ASSETS / 'fonts/manifest.json').read_bytes() font_manifest = json.loads(font_manifest_raw) diff --git a/verifier/visual_style.py b/verifier/visual_style.py index 497ff9d..11d5423 100644 --- a/verifier/visual_style.py +++ b/verifier/visual_style.py @@ -37,8 +37,9 @@ def luminance(color): def load_profile(path=None): - data = json.loads(Path(path or ASSETS / "profile.json").read_text(), object_pairs_hook=_unique, - parse_constant=lambda value: (_ for _ in ()).throw(ValueError(f"Nonfinite {value}"))) + data = deepcopy(path) if isinstance(path, dict) else json.loads( + Path(path or ASSETS / "profile.json").read_text(), object_pairs_hook=_unique, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError(f"Nonfinite {value}"))) expected = {"version", "name", "direction_status", "palette", "fonts", "body_variants", "numeric_proposal", "limits"} if not isinstance(data, dict) or set(data) != expected or type(data["version"]) is not int or data["version"] != 1: raise ValueError("Expected visual profile version 1 and exact documented fields") @@ -108,7 +109,7 @@ def font_css(): def presentation_style(profile=None, *, body="paper", target_width=1200, canvas_width=1200): - data = load_profile(profile) if profile is None or isinstance(profile, (str, Path)) else _validated_dict(profile) + data = load_profile(profile) for value in (target_width, canvas_width): if type(value) not in (int, float) or not math.isfinite(value) or not 200 <= value <= 20000: raise ValueError("Canvas and target widths need finite values from 200 to 20000") @@ -126,11 +127,3 @@ def presentation_style(profile=None, *, body="paper", target_width=1200, canvas_ "font_css": font_css(), "body_status": "provisional", "contrast": {"body_text": contrast(palette["ink"], fill), "accent_text": contrast(palette["families"]["tangerine"]["shade"], palette["families"]["violet"]["tint"])}} - - -def _validated_dict(profile): - # Same validation for dictionaries without executing or persisting model-supplied code. - default = load_profile() - if profile != default: - raise ValueError("Custom profiles must be supplied as a file for strict validation") - return deepcopy(default) From cc772add4558035abc8f601343f1effd90b4a895 Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:43:41 +0000 Subject: [PATCH 04/10] Size presentation geometry for qualified font ranges --- tests/test_visual_projection.py | 92 +++++++++++++++++++++++++++++++++ verifier/visual_projection.py | 44 +++++++++++----- 2 files changed, 124 insertions(+), 12 deletions(-) diff --git a/tests/test_visual_projection.py b/tests/test_visual_projection.py index 9f1b40b..8c1a8e6 100644 --- a/tests/test_visual_projection.py +++ b/tests/test_visual_projection.py @@ -2,6 +2,7 @@ from copy import deepcopy from hashlib import sha256 import json +import re from pathlib import Path import subprocess import sys @@ -12,6 +13,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) +from verifier.visual_style import load_profile from verifier.sysml import CLASSES, JAR from verifier.visual_projection import PresentationError, project_function_tree, render_function_tree, write_presentation @@ -72,6 +74,74 @@ def test_parsed_svg_preserves_title_body_roles_and_square_containers(self): self.assertNotIn('rx', rectangle.attrib) self.assertNotIn('ry', rectangle.attrib) + def test_profile_sizes_keep_line_spacing_containment_and_parent_endpoints(self): + nodes, inventory = fixture() + projection = project(nodes, inventory) + namespace = {'svg': 'http://www.w3.org/2000/svg'} + for size in (20, 22, 24): + with self.subTest(body_size=size): + profile = load_profile() + profile['numeric_proposal']['body_size'] = size + svg, ledger = render_function_tree(projection, profile=profile, target_width=2200) + root = ET.fromstring(svg) + boxes = {} + for group in root.findall('svg:g', namespace): + key = group.attrib['data-id'] + rect = group.find('svg:rect', namespace) + box = {k: float(rect.attrib[k]) for k in ('x', 'y', 'width', 'height')} + boxes[key] = box + metadata, label = group.findall('svg:text', namespace) + top_baseline, bottom_baseline = float(metadata.attrib['y']), float(label.attrib['y']) + self.assertEqual(float(metadata.attrib['font-size']), size) + self.assertEqual(float(label.attrib['font-size']), size) + self.assertGreaterEqual(bottom_baseline - top_baseline, 1.5 * size) + self.assertGreaterEqual(top_baseline - size, box['y'] + 20) + self.assertLessEqual(bottom_baseline + size * 0.4, box['y'] + box['height'] - 16) + self.assertLess(box['y'] + box['height'], ledger['canvas_height']) + ordered = sorted(boxes.values(), key=lambda box: box['y']) + for prior, following in zip(ordered, ordered[1:]): + self.assertGreaterEqual(following['y'] - prior['y'] - prior['height'], 32) + for path in root.findall('svg:path', namespace): + parent = boxes[path.attrib['data-parent']] + child = boxes[path.attrib['data-child']] + start_x, start_y, end_y, end_x = map(float, re.findall(r'[0-9]+(?:\.[0-9]+)?', path.attrib['d'])) + self.assertTrue(parent['x'] < start_x < parent['x'] + parent['width']) + self.assertEqual(start_y, parent['y'] + parent['height']) + self.assertEqual(end_y, child['y'] + child['height'] / 2) + self.assertEqual(end_x, child['x']) + self.assertEqual(ledger['layout']['line_height'], size * 1.5) + self.assertIn('require browser inspection', ledger['layout']['scope']) + self.assertEqual(projection['nodes'][0]['text'], 'Process item') + + def test_wide_view_and_metadata_ids_use_selected_font_size_bounds(self): + nodes, inventory = fixture() + nodes[0]['id'] = 'W' * 64 + nodes[1]['parent'] = nodes[2]['parent'] = nodes[0]['id'] + project(nodes, inventory) + view_id = 'W' * 64 + data = json.loads(inventory['documents'][0]['body'].split('\n', 1)[1]) + data['views'][0]['id'] = view_id + inventory['documents'][0]['body'] = 'CSE-VIEWS/1.0\n' + json.dumps(data) + projection = project_function_tree(inventory, view_id) + for title_size, body_size in ((36, 22), (48, 24)): + with self.subTest(title_size=title_size, body_size=body_size): + profile = load_profile() + profile['numeric_proposal'].update(title_size=title_size, body_size=body_size) + with self.assertRaisesRegex(ValueError, 'Target too small'): + render_function_tree(projection, profile=profile, target_width=2200) + svg, ledger = render_function_tree(projection, profile=profile, target_width=7200) + tree = ET.fromstring(svg) + title = tree.find('{http://www.w3.org/2000/svg}text') + self.assertEqual(title.text, view_id + ' - Function decomposition') + self.assertGreaterEqual(ledger['canvas_width'] - float(title.attrib['x']) - 48, + len(title.text) * 1.5 * title_size) + for group in tree.findall('{http://www.w3.org/2000/svg}g'): + rectangle = group.find('{http://www.w3.org/2000/svg}rect') + for text in group.findall('{http://www.w3.org/2000/svg}text'): + self.assertGreaterEqual(float(rectangle.attrib['width']) - 40, + len(text.text) * 1.5 * body_size) + self.assertIn(view_id, svg) + def test_invalid_structure_bindings_and_requested_views_refused(self): for defect in ('cycle', 'orphan', 'level', 'binding', 'duplicate', 'missing', 'kind'): nodes, inventory = fixture() @@ -174,6 +244,28 @@ def test_native_cli_accepts_nondefault_profile_and_preserves_provisional_status( self.assertTrue(all(label.attrib['font-size'] == '23' for label in labels)) self.assertEqual(profile_path.read_text(), json.dumps(profile)) + def test_native_wide_title_refuses_small_target_without_output(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: + temp = Path(temp) + view_id = 'W' * 64 + source = temp / 'wide.sysml' + source.write_text(MODEL.read_text().replace('"id": "FT"', '"id": "' + view_id + '"')) + profile = load_profile() + profile['numeric_proposal'].update(title_size=48, body_size=24) + small = temp / 'too-small' + with self.assertRaisesRegex(ValueError, 'Target too small'): + write_presentation(source, small, view_id=view_id, profile=profile, target_width=2200) + self.assertFalse(small.exists()) + wide = temp / 'wide' + result = write_presentation(source, wide, view_id=view_id, profile=profile, target_width=7200) + self.assertEqual(result['status'], 'pass') + title = ET.parse(wide / 'view.svg').find('{http://www.w3.org/2000/svg}text') + self.assertEqual(title.text, view_id + ' - Function decomposition') + presentation = json.loads((wide / 'presentation.json').read_text()) + self.assertGreaterEqual(presentation['canvas_width'] - 96, len(title.text) * 1.5 * 48) + self.assertEqual((wide / 'sources/model-1.sysml').read_bytes(), source.read_bytes()) + def test_cli_invalid_view_and_missing_parser_diagnostics(self): (ROOT / '.tmp').mkdir(exist_ok=True) with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: diff --git a/verifier/visual_projection.py b/verifier/visual_projection.py index 2657f0d..4d528ab 100644 --- a/verifier/visual_projection.py +++ b/verifier/visual_projection.py @@ -82,38 +82,58 @@ def render_function_tree(projection, *, profile=None, body="paper", target_width key = pending.pop() order.append(key) pending.extend(reversed(children[key])) - # Each label has its own row. Horizontal scrolling preserves readable type. - # Conservative per-codepoint width prevents clipping without a font renderer dependency. - width = max(1200, 96 + (len(projection["id"]) + 25) * 28, max(104 + n["level"] * 48 + max(len(n["text"]), len(n["id"])) * 30 for n in nodes)) - height = 160 + 104 * len(nodes) + # Validate the selected profile before using any numerical layout values. + # Qualified printable glyphs get a conservative 1.5-em advance bound; + # actual loaded glyph bounds remain a separate browser inspection gate. + style = presentation_style(profile, body=body, target_width=1200, canvas_width=1200) + title_text = projection["id"] + " - Function decomposition" + body_advance = 1.5 * style["body_size"] + title_advance = 1.5 * style["title_size"] + width = math.ceil(max(1200, 96 + len(title_text) * title_advance, + *(48 + n["level"] * 48 + 40 + 48 + body_advance * max( + len(n["text"]), len(f"{n['id']} · level {n['level']}")) for n in nodes))) + # Reapply the readable-label gate to the complete calculated canvas. style = presentation_style(profile, body=body, target_width=target_width, canvas_width=width) - positions = {key: (48 + by_id[key]["level"] * 48, 112 + i * 104) for i, key in enumerate(order)} + # Conservative line boxes depend on the qualified profile size. Browser + # glyph-bounds inspection remains a separate check, not an arithmetic claim. + padding = 20 + line_height = math.ceil(1.5 * style["body_size"]) + first_baseline = padding + math.ceil(1.2 * style["body_size"]) + second_baseline = first_baseline + line_height + node_height = 2 * line_height + 2 * padding + row_stride = node_height + 32 + height = 112 + len(nodes) * row_stride + 16 + positions = {key: (48 + by_id[key]["level"] * 48, 112 + i * row_stride) for i, key in enumerate(order)} svg = [f'', f'{escape(projection["id"])} - function decomposition', f'{escape(CLAIM)}', '', f'', - f'{escape(projection["id"])} - Function decomposition', + f'{escape(title_text)}', f'Documentary parent links · no behavioral or engineering acceptance claim'] for key in order: node = by_id[key] if node['parent'] is not None: px, py = positions[node['parent']] x, y = positions[key] - svg.append(f'') + svg.append(f'') geometry = [] for key in order: node = by_id[key] x, y = positions[key] box_width = width - x - 48 attrs = ' '.join(f'data-{k}="{escape(str(node[k]), quote=True)}"' for k in ("id", "element", "level", "parent")) - svg.extend([f'', f'', - f'{key} · level {node["level"]}', - f'{escape(node["text"])}', '']) - geometry.append({"id": key, "x": x, "y": y, "width": box_width, "height": 72}) + svg.extend([f'', f'', + f'{key} · level {node["level"]}', + f'{escape(node["text"])}', '']) + geometry.append({"id": key, "x": x, "y": y, "width": box_width, "height": node_height}) svg.append('') ledger = {"version": VERSION, "body": body, "target_width": target_width, "canvas_width": width, - "canvas_height": height, "claim_scope": CLAIM, "geometry": geometry, "style": style} + "canvas_height": height, "claim_scope": CLAIM, + "layout": {"line_height": line_height, "node_height": node_height, + "row_stride": row_stride, "padding": padding, + "first_baseline": first_baseline, "second_baseline": second_baseline, + "scope": "Conservative font-size-based spacing; actual loaded glyph bounds require browser inspection."}, "geometry": geometry, "style": style} return '\n'.join(svg) + '\n', ledger From cc71bb47bb87c2909d03e117c9298fa6bd71fb0b Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:43:41 +0000 Subject: [PATCH 05/10] Preserve portable visual specification and open design choices --- assets/diagram-style/design-tokens.json | 504 ++++++++++++++++++++++++ docs/visual-design-spec.md | 109 +++++ docs/visual-presentation.md | 2 + 3 files changed, 615 insertions(+) create mode 100644 assets/diagram-style/design-tokens.json create mode 100644 docs/visual-design-spec.md diff --git a/assets/diagram-style/design-tokens.json b/assets/diagram-style/design-tokens.json new file mode 100644 index 0000000..55b5c5c --- /dev/null +++ b/assets/diagram-style/design-tokens.json @@ -0,0 +1,504 @@ +{ + "schema": "engineering-visual-tokens/1", + "status": "SANITIZED DESIGN SPECIFICATION; NOT AN IMPLEMENTATION OR ACCEPTANCE RECORD", + "palette": { + "status": "SELECTED_COLLECTION", + "values": { + "id": "03", + "name": "Graphite and signal", + "paper": "#eeeeea", + "ink": "#25282d", + "families": [ + { + "name": "Signal blue", + "shade": "#27456f", + "base": "#32588e", + "tint": "#d2dae6" + }, + { + "name": "Brick red", + "shade": "#7f362f", + "base": "#a3453c", + "tint": "#ebd6d4" + }, + { + "name": "Amber", + "shade": "#825d19", + "base": "#a77720", + "tint": "#ece1ce" + }, + { + "name": "Instrument green", + "shade": "#315e43", + "base": "#3f7956", + "tint": "#d5e2da" + }, + { + "name": "Cyan blue", + "shade": "#2e6371", + "base": "#3b7f91", + "tint": "#d4e3e7" + }, + { + "name": "Violet", + "shade": "#58436f", + "base": "#71568e", + "tint": "#e0dae6" + }, + { + "name": "Tangerine", + "shade": "#894b25", + "base": "#b06030", + "tint": "#eedcd1" + }, + { + "name": "Berry red", + "shade": "#753c53", + "base": "#964d6b", + "tint": "#e8d8de" + } + ], + "neutrals": [ + { + "name": "Paper", + "hex": "#eeeeea" + }, + { + "name": "Light neutral", + "hex": "#dededb" + }, + { + "name": "Middle neutral", + "hex": "#b6b7b5" + }, + { + "name": "Ink", + "hex": "#25282d" + } + ] + }, + "universal_semantic_roles_selected": false, + "scoped_preference": { + "foreground": "#894b25", + "background": "#e0dae6", + "establishes_body_default": false + } + }, + "typography": { + "status": "SELECTED_FAMILY_ROLES", + "roles": { + "titles": "Inter", + "most_other_text": "Space Grotesk" + }, + "faces": [ + { + "family": "Inter", + "weight": 400, + "style": "normal", + "width": "normal", + "format": "woff2", + "package": "@fontsource/inter", + "package_version": "5.3.0", + "metadata_version": "v20", + "license": "SIL Open Font License 1.1", + "sha256": "8909904ab6c872eb994093482a88a28eca2cd95912d7b6fecd72103b0dc07edc" + }, + { + "family": "Inter", + "weight": 700, + "style": "normal", + "width": "normal", + "format": "woff2", + "package": "@fontsource/inter", + "package_version": "5.3.0", + "metadata_version": "v20", + "license": "SIL Open Font License 1.1", + "sha256": "6f56409fd3d64bb85f7d070bce20749db2d66b6d63cec586cc22d1c761be2491" + }, + { + "family": "Space Grotesk", + "weight": 400, + "style": "normal", + "width": "normal", + "format": "woff2", + "package": "@fontsource/space-grotesk", + "package_version": "5.3.0", + "metadata_version": "v22", + "license": "SIL Open Font License 1.1", + "sha256": "65fd17fcbd2e2f522940b5f67ead3d23329e02891aa5495e74d11a499c0b0673" + }, + { + "family": "Space Grotesk", + "weight": 700, + "style": "normal", + "width": "normal", + "format": "woff2", + "package": "@fontsource/space-grotesk", + "package_version": "5.3.0", + "metadata_version": "v22", + "license": "SIL Open Font License 1.1", + "sha256": "35f8aec56cfd5cbfdb03cc68733a54a0b05bb3617ffcd5fd332badc0b045ca55" + } + ], + "universal_weight_roles_selected": false, + "universal_sizes_selected": false, + "measurement": "Measure loaded glyphs at the delivered scale; preserve codepoints, subscripts and units. Report unsupported coverage.", + "fitting": "Do not force width, stretch, tracking or text-fitting transforms. Wrap or resize the content region. Preserve legitimate notation rotations. Uniform viewer zoom is separate." + }, + "illustration_profiles": { + "status": "SELECTED_STYLE_DIRECTION", + "selected": "Drafted front, profile and orthographic exterior cues", + "unselected_alternatives": [ + "Oblique relief", + "Compact silhouettes" + ], + "semantic_boundary": "Decorative cues do not establish physical geometry, dimensions, counts, containment or allocation.", + "universal_glyph_geometry_selected": false + }, + "containers": { + "status": "SELECTED_FORM_DIRECTION", + "form": "Square-corner content-fit single frame with useful information partitions", + "decorative_nested_frames": false, + "notation_precedence": true, + "example_geometry": { + "status": "ILLUSTRATIVE_NOT_UNIVERSAL", + "larger_container": { + "width": 420, + "height": 240, + "border": 2, + "side_key": 8, + "radius": 0 + }, + "smaller_container": { + "width": 400, + "height": 180, + "border": 2, + "side_key": 7, + "radius": 0 + }, + "unit": "CSS px", + "useful_divider_proposal": 1.5 + }, + "partition_layout": "Header, body and footer cells may share one frame when each carries useful information. They do not create model compartments or containment.", + "color_roles_status": "PROPOSED_NOT_UNIVERSALLY_SELECTED", + "purple_policy": "Retain a reworked purple option; no universal exclusion." + }, + "body_treatment": { + "status": "OPEN", + "selected": null, + "options": { + "white": "#ffffff", + "paper": "#eeeeea", + "faint": { + "violet": "#f6f5f8", + "blue": "#f3f5f8", + "tangerine": "#faf5f3", + "green": "#f3f7f5" + } + }, + "faint_formula": { + "alpha": 0.06, + "background": "#ffffff", + "per_sRGB_channel": "floor((1-alpha)*255 + alpha*baseChannel + 0.5)" + }, + "common_quantity_and_index_ink": "#25282d", + "policy": "Keep essential text, numbers and symbols visually distinct from the body. These options do not add palette families." + }, + "conditional_profiles": { + "general_sheet": { + "status": "RECOMMENDED_NOT_SELECTED", + "reference_canvas": { + "width": 1600, + "height": 1100, + "unit": "CSS px", + "zoom": 1 + }, + "ranges": { + "page_title": { + "min": 32, + "max": 48, + "default": 40, + "unit": "px", + "applicability": "1600px native sheet, title", + "exceptions": "Content fit and delivery scale" + }, + "node_title": { + "min": 24, + "max": 32, + "default": 28, + "unit": "px", + "applicability": "Native engineering-node title", + "exceptions": "Long labels: wrap/resize, preserve meaning" + }, + "body": { + "min": 20, + "max": 24, + "default": 22, + "unit": "px", + "applicability": "Native labels and body text", + "exceptions": "Narrow screens scroll/reflow; do not silently shrink" + }, + "annotation": { + "min": 18, + "max": 20, + "default": 18, + "unit": "px", + "applicability": "Secondary nonessential context", + "exceptions": "Never hide essential meaning in unreadable text" + }, + "padding": { + "min": 20, + "max": 32, + "default": 24, + "unit": "px", + "applicability": "Plain presentation box interior", + "exceptions": "Ports/labels/notation may need more" + }, + "related_gap": { + "min": 12, + "max": 24, + "default": 16, + "unit": "px", + "applicability": "Related plain content items", + "exceptions": "Measured labels may require larger gaps" + }, + "group_gap": { + "min": 32, + "max": 64, + "default": 40, + "unit": "px", + "applicability": "Distinct visual groups", + "exceptions": "Source routing/containment takes precedence" + }, + "label_clearance": { + "min": 8, + "max": 16, + "default": 12, + "unit": "px", + "applicability": "Label to essential route/terminal", + "exceptions": "Long text, branches, print scale" + }, + "bend_clearance": { + "min": 16, + "max": 32, + "default": 24, + "unit": "px", + "applicability": "Distance from bends/terminals to nearby obstacles or labels; never a route-to-endpoint gap", + "exceptions": "Endpoint connection gap is zero; preserve source attachment side/order and node boundaries" + }, + "container_width": { + "min": 320, + "max": 560, + "default": 400, + "unit": "px", + "applicability": "Simple untyped presentation container", + "exceptions": "Notation-specific nodes and content fit override" + }, + "line_weight": { + "min": 1.5, + "max": 3, + "default": 2, + "unit": "px", + "applicability": "Opaque essential strokes on native sheets", + "exceptions": "Source notation or deliberate emphasis may differ" + }, + "title_leading": { + "min": 1.15, + "max": 1.3, + "default": 1.2, + "unit": "ratio", + "applicability": "Multiline Inter title", + "exceptions": "Glyph/content measurement" + }, + "body_leading": { + "min": 1.35, + "max": 1.5, + "default": 1.4, + "unit": "ratio", + "applicability": "Multiline Space Grotesk body", + "exceptions": "Dense symbolic notation may need an explicit exception" + } + } + }, + "large_graphic_samples": { + "status": "RECOMMENDED_NOT_SELECTED", + "reference_width": 1600, + "unit": "CSS px", + "ranges": { + "line_weight": { + "min": 3, + "max": 6, + "default": 4, + "unit": "px", + "applicability": "Ordinary graphic samples at native 1600 px; source notation prevails", + "exceptions": "No automatic style change to typed relations" + }, + "compact_padding": { + "min": 12, + "max": 16, + "default": 14, + "unit": "px", + "applicability": "Content-fit plain containers", + "exceptions": "Source compartments/long labels require measurement" + }, + "standard_padding": { + "min": 18, + "max": 24, + "default": 20, + "unit": "px", + "applicability": "Plain content-fit containers", + "exceptions": "Nested ports and labels may need more space" + } + }, + "illustrative_text_sizes": { + "titles": [ + 28, + 32, + 44 + ], + "body": [ + 20, + 22, + 24 + ] + } + }, + "resolution": "Profiles apply to different content and scale conditions. Do not combine them into a universal range. Model notation, content fit and delivery scale take precedence." + }, + "line_and_texture_samples": { + "status": "VISUAL_DIRECTION_WITH_UNSELECTED_SEMANTIC_ROLES", + "line_samples": [ + { + "name": "Solid", + "weight": 4, + "dasharray": "", + "color": "#27456f" + }, + { + "name": "Dash", + "weight": 4, + "dasharray": "26 12", + "color": "#894b25" + }, + { + "name": "Dot", + "weight": 6, + "dasharray": "0 17", + "color": "#315e43" + }, + { + "name": "Dash-dot", + "weight": 4, + "dasharray": "26 10 2 10", + "color": "#7f362f" + }, + { + "name": "Spotted", + "weight": null, + "dasharray": null, + "color": "#27456f" + }, + { + "name": "Striped band", + "weight": null, + "dasharray": null, + "color": "#27456f" + }, + { + "name": "Color channels", + "weight": null, + "dasharray": null, + "color": "#315e43" + } + ], + "unit": "CSS px at the large graphic reference scale", + "universal_relation_mapping_selected": false, + "texture_policy": "Exploratory decoration only in nonessential regions. Preserve declared material or equipment hatches and stipple. Decorative patterns do not establish material properties or relation types.", + "essential_content": "Use opaque contrasting fields and retain noncolor distinctions." + }, + "diagram_families": { + "labels": [ + "System context and interconnection", + "Composition and hierarchy", + "Functional flow", + "Activity", + "State machine", + "Sequence", + "Electrical and electronic schematic", + "Concept illustration", + "Traceability matrix", + "Physical geometry" + ], + "status": "DESCRIPTIVE LABELS ONLY; NOT A PRODUCT SUPPORT MATRIX", + "support_rule": "Each supported family needs an explicit admitted model contract, notation rules and tests. Unsupported families remain unsupported." + }, + "notation_invariants": [ + "The authoritative engineering model determines element identity, ownership, type, applicability, units, thresholds and obligations.", + "Preserve declared relation direction and kind, port identities, endpoint attachments, side and order, multiplicity and cardinality.", + "Keep junctions distinct from independent crossings; do not infer a junction from a visual intersection.", + "Use arrowheads only where declared direction or sequence warrants them. Preserve composition-diamond ownership, control versus object flow, fork/join and state-exit semantics.", + "Preserve declared resistor, transistor, switch and other specialized notation; a decorative pictogram is not a typed model element.", + "Actual connections meet their declared contacts. Clearance applies to labels and obstacles. Preserve explicitly nonattached marks and documented gaps without inventing links.", + "Do not create model containment, allocations or counts from boxes, facets, fasteners, icon marks or visual groupings.", + "Unknown or ambiguous meaning remains explicit. An illustration does not become a formal systems model through styling." + ], + "crispness_method": { + "goal": "Legible text, deliberate stroke hierarchy and clear separation at the delivery scale", + "measurement": [ + "Compare baseline and candidate at the same canvas, browser renderer, device-pixel ratio and display scale.", + "Compute effective painted widths through scene, element, pattern and instance transforms; nominal attributes alone are insufficient.", + "Inspect text bounds, clipping, overlaps, arrowheads, dash patterns, hatch density, connections and crossings.", + "Preserve editable vectors and text with qualified embedded fonts. Generate higher-resolution rasters by fresh vector rendering.", + "Use normal antialiasing for curves and diagonals; do not apply a universal crispEdges override." + ], + "limits": [ + "A thicker or thinner stroke does not universally improve sharpness.", + "Thumbnail fit factors do not establish equal text size or physical scale.", + "Nominal CSS pixels per inch do not calibrate a monitor or printer.", + "Visual preference and numerical checks do not establish engineering acceptance or reader comprehension." + ] + }, + "acceptance_proposals": { + "status": "PROPOSED; EXECUTION AND ENGINEER ACCEPTANCE NOT CLAIMED", + "gates": [ + { + "name": "Model projection", + "expectation": "Check identity, ownership, endpoint kinds, topology, notation, units, cardinalities and text against the admitted model." + }, + { + "name": "Meaningful refusal tests", + "expectation": "Use synthetic negative fixtures for wrong endpoints, missing required inputs, altered model inputs and unsupported notation. Required unknown, errored or incomplete checks cannot yield overall success." + }, + { + "name": "Typography", + "expectation": "Check actual loaded faces, glyph coverage, codepoints, subscripts, metrics, font notices and absence of silent fallback." + }, + { + "name": "Layout and contrast", + "expectation": "Check clipping, collisions, label and port clearances, contact attachment, arrowheads and measurable contrast at intended scales. Do not use color alone for essential meaning." + }, + { + "name": "Artifact generation", + "expectation": "Regenerate from trusted model data; inspect native vectors and fresh raster exports. If offline viewing is delivered, test it without network resources and verify saved source identity." + }, + { + "name": "Repository integration", + "expectation": "Run required scoped and repository regressions on the assigned integration base. Preserve existing public interfaces and reject execution of code embedded in model data." + }, + { + "name": "Review and acceptance", + "expectation": "Keep semantic review, visual review, engineering acceptance and physical qualification separate. Hashes identify bytes rather than authenticate decisions." + }, + { + "name": "Reuse rights", + "expectation": "Use synthetic fixtures, original reusable artwork or explicitly permitted attributed derivatives. Include required licenses with redistributed fonts and assets." + } + ], + "additional_qualification": [ + "Graphical-editor edit, save and reopen where editable delivery is claimed.", + "Print, grayscale and monitor-scale qualification for the intended medium.", + "Accessibility and representative reader-comprehension evaluation." + ] + }, + "delivery_boundary": "This document contains reusable presentation values and proposals only. It embeds no source figures, models, artwork, font binaries or evidence records and grants no rights to omitted material." +} diff --git a/docs/visual-design-spec.md b/docs/visual-design-spec.md new file mode 100644 index 0000000..be9ab30 --- /dev/null +++ b/docs/visual-design-spec.md @@ -0,0 +1,109 @@ +# Portable engineering visual specification + +This specification records reusable presentation choices and proposed checks. It is suitable for implementation documentation, subject to engineering review. It does not claim implemented support, verified behavior or accepted engineering meaning. + +## Decision status + +Selected directions are the palette below, Inter titles, Space Grotesk for most other text, drafted front/profile/orthographic exterior cues, square-corner content-fit framing and useful information partitions. Oblique relief and compact silhouettes are unselected alternatives. Illustration cues do not establish physical geometry, dimensions, counts, containment or allocations. + +White, paper and faint-tint box bodies remain open. Numerical sizes, spacing and weights are conditional recommendations. Universal line, texture and color meanings remain unselected. A scoped preference for orange `#894b25` on violet tint `#e0dae6` does not establish a universal body fill or engineering semantic role. + +## Palette + +| Family | Shade | Base | Tint | +| --- | --- | --- | --- | +| Signal blue | `#27456f` | `#32588e` | `#d2dae6` | +| Brick red | `#7f362f` | `#a3453c` | `#ebd6d4` | +| Amber | `#825d19` | `#a77720` | `#ece1ce` | +| Instrument green | `#315e43` | `#3f7956` | `#d5e2da` | +| Cyan blue | `#2e6371` | `#3b7f91` | `#d4e3e7` | +| Violet | `#58436f` | `#71568e` | `#e0dae6` | +| Tangerine | `#894b25` | `#b06030` | `#eedcd1` | +| Berry red | `#753c53` | `#964d6b` | `#e8d8de` | + +Neutrals are paper `#eeeeea`, light `#dededb`, middle `#b6b7b5` and ink `#25282d`. White `#ffffff` is a body option, not another palette family. Essential labels need opaque contrasting fields and distinctions that do not depend on color alone. + +## Typography + +Use Inter for titles and Space Grotesk for most other text. Normal-width regular and bold faces are available; universal weight roles and numerical sizes remain open. The identified WOFF2 faces use SIL Open Font License 1.1. Package metadata is @fontsource 5.3.0, with Inter v20 and Space Grotesk v22 metadata. Include the applicable upstream copyright and license notices when redistributing font files; no binaries are included here. + +| Family | Weight | Face SHA-256 | +| --- | --- | --- | +| Inter | 400 | `8909904ab6c872eb994093482a88a28eca2cd95912d7b6fecd72103b0dc07edc` | +| Inter | 700 | `6f56409fd3d64bb85f7d070bce20749db2d66b6d63cec586cc22d1c761be2491` | +| Space Grotesk | 400 | `65fd17fcbd2e2f522940b5f67ead3d23329e02891aa5495e74d11a499c0b0673` | +| Space Grotesk | 700 | `35f8aec56cfd5cbfdb03cc68733a54a0b05bb3617ffcd5fd332badc0b045ca55` | + +Measure actual loaded glyphs. Preserve codepoints, scientific symbols, subscripts and units; explicitly qualify any supplement and report unsupported coverage. Do not force text width, stretch, tracking or fitting transforms. Wrap or resize the content region. Preserve legitimate notation rotations. Uniform viewer zoom is a separate presentation operation. + +## Framing and open body choices + +Use one content-fit frame with meaningful partitions. Header, body and footer cells are useful when each carries information. Decorative nested frames must not imply engineering containment. Native notation and model-owned compartments take precedence. Retain a reworked purple option; there is no universal purple exclusion. + +Illustrative plain containers are 420 × 240 and 400 × 180 CSS px, with 2 px borders, square corners and optional 8 px or 7 px side keys. These dimensions are examples, not required defaults. A 1.5 px divider is proposed only for useful partitions. + +Open bodies are white `#ffffff`, paper `#eeeeea`, or faint tints: violet `#f6f5f8`, blue `#f3f5f8`, tangerine `#faf5f3`, green `#f3f7f5`. The faint proposal uses 6% of an existing base over white: `floor((1-alpha)*255 + alpha*baseChannel + 0.5)` per sRGB channel, with alpha 0.06. It adds no palette family. Common quantity and index text uses ink `#25282d`; keep essential text, numbers and symbols visually distinct from the body. + +## Conditional ranges + +These recommendations assume a 1600 × 1100 CSS px authored sheet at zoom 1. They are presentation units, never physical engineering dimensions. Retain the applicability and exception for each range. + +| Parameter | Range | Starting value | Unit | Applicability and exception | +| --- | --- | --- | --- | --- | +| page title | 32–48 | 40 | px | 1600px native sheet, title; Content fit and delivery scale | +| node title | 24–32 | 28 | px | Native engineering-node title; Long labels: wrap/resize, preserve meaning | +| body | 20–24 | 22 | px | Native labels and body text; Narrow screens scroll/reflow; do not silently shrink | +| annotation | 18–20 | 18 | px | Secondary nonessential context; Never hide essential meaning in unreadable text | +| padding | 20–32 | 24 | px | Plain presentation box interior; Ports/labels/notation may need more | +| related gap | 12–24 | 16 | px | Related plain content items; Measured labels may require larger gaps | +| group gap | 32–64 | 40 | px | Distinct visual groups; Source routing/containment takes precedence | +| label clearance | 8–16 | 12 | px | Label to essential route/terminal; Long text, branches, print scale | +| bend clearance | 16–32 | 24 | px | Distance from bends/terminals to nearby obstacles or labels; never a route-to-endpoint gap; Endpoint connection gap is zero; preserve source attachment side/order and node boundaries | +| container width | 320–560 | 400 | px | Simple untyped presentation container; Notation-specific nodes and content fit override | +| line weight | 1.5–3 | 2 | px | Opaque essential strokes on native sheets; Source notation or deliberate emphasis may differ | +| title leading | 1.15–1.3 | 1.2 | ratio | Multiline Inter title; Glyph/content measurement | +| body leading | 1.35–1.5 | 1.4 | ratio | Multiline Space Grotesk body; Dense symbolic notation may need an explicit exception | + +A separate large-graphic profile at 1600 CSS px width proposes 3–6 px strokes, starting at 4; compact padding 12–16 px, starting at 14; and standard padding 18–24 px, starting at 20. Illustrative title sizes are 28, 32 and 44 px; body sizes are 20, 22 and 24 px. This profile applies to large graphic samples. Do not combine it with the general-sheet profile into a universal range or silently change typed notation. + +## Lines, textures and notation + +Solid, dashed, dotted, dash-dot, spotted, striped-band and color-channel samples are available visual directions. Universal relation mappings are unselected. At the large-graphic reference scale, illustrative strokes are solid 4 px; dashed 4 px with array `26 12`; dotted 6 px with array `0 17`; and dash-dot 4 px with array `26 10 2 10`. Dotted samples require an explicitly qualified cap treatment. Preserve native notation instead of imposing these arrays globally. + +Textures are exploratory decoration in nonessential regions. Preserve declared equipment hatches, material stipple and other semantic patterns. Decorative textures do not establish material properties or relation types. + +- The authoritative engineering model determines element identity, ownership, type, applicability, units, thresholds and obligations. +- Preserve declared relation direction and kind, port identities, endpoint attachments, side and order, multiplicity and cardinality. +- Keep junctions distinct from independent crossings; do not infer a junction from a visual intersection. +- Use arrowheads only where declared direction or sequence warrants them. Preserve composition-diamond ownership, control versus object flow, fork/join and state-exit semantics. +- Preserve declared resistor, transistor, switch and other specialized notation; a decorative pictogram is not a typed model element. +- Actual connections meet their declared contacts. Clearance applies to labels and obstacles. Preserve explicitly nonattached marks and documented gaps without inventing links. +- Do not create model containment, allocations or counts from boxes, facets, fasteners, icon marks or visual groupings. +- Unknown or ambiguous meaning remains explicit. An illustration does not become a formal systems model through styling. + +Public diagram-family descriptions include system context/interconnection, composition/hierarchy, functional flow, activity, state machine, sequence, electrical/electronic schematic, concept illustration, traceability matrix and physical geometry. These labels are not a support matrix. Each supported family needs an explicit admitted model contract, notation rules and tests. Unsupported families remain unsupported. + +## Crispness method + +Compare baseline and candidate at the same canvas, browser renderer, device-pixel ratio and display scale. Compute effective painted widths through scene, element, pattern and instance transforms; raw stroke attributes alone are insufficient. Inspect text bounds, clipping, overlaps, arrows, dashes, hatch density, connections and crossings at the delivery scale. + +Preserve editable vectors and text with qualified embedded fonts. Generate higher-resolution rasters by fresh vector rendering. Curves and diagonals use normal antialiasing; do not impose a universal crispEdges override. Thicker or thinner strokes do not universally improve sharpness. Thumbnail fit factors cannot establish equal text size, and nominal CSS pixels per inch do not calibrate a monitor or printer. + +## Proposed acceptance checks + +The following are integration proposals. Their inclusion does not report execution or engineer acceptance. + +- **Model projection:** Check identity, ownership, endpoint kinds, topology, notation, units, cardinalities and text against the admitted model. +- **Meaningful refusal tests:** Use synthetic negative fixtures for wrong endpoints, missing required inputs, altered model inputs and unsupported notation. Required unknown, errored or incomplete checks cannot yield overall success. +- **Typography:** Check actual loaded faces, glyph coverage, codepoints, subscripts, metrics, font notices and absence of silent fallback. +- **Layout and contrast:** Check clipping, collisions, label and port clearances, contact attachment, arrowheads and measurable contrast at intended scales. Do not use color alone for essential meaning. +- **Artifact generation:** Regenerate from trusted model data; inspect native vectors and fresh raster exports. If offline viewing is delivered, test it without network resources and verify saved source identity. +- **Repository integration:** Run required scoped and repository regressions on the assigned integration base. Preserve existing public interfaces and reject execution of code embedded in model data. +- **Review and acceptance:** Keep semantic review, visual review, engineering acceptance and physical qualification separate. Hashes identify bytes rather than authenticate decisions. +- **Reuse rights:** Use synthetic fixtures, original reusable artwork or explicitly permitted attributed derivatives. Include required licenses with redistributed fonts and assets. + +Where relevant, separately qualify graphical-editor edit/save/reopen, print and grayscale behavior, monitor scale, accessibility and representative reader comprehension. Numerical passes and visual preference do not establish physical correctness or engineering acceptance. + +## Reuse boundary + +This specification includes presentation tokens and generic engineering rules. It embeds no source figures, models, artwork, font binaries or evidence records and grants no rights to omitted material. Use synthetic fixtures, original reusable artwork or explicitly permitted attributed derivatives for implementation examples. diff --git a/docs/visual-presentation.md b/docs/visual-presentation.md index 790de36..96e25df 100644 --- a/docs/visual-presentation.md +++ b/docs/visual-presentation.md @@ -12,6 +12,8 @@ Use the CLI help for the current flags. The output directory must be new. The na Edit the authoritative SysML and regenerate into another new directory. Do not patch exported SVG to change the engineering model. The output includes editable SVG text, an offline HTML reader, a semantic ledger, presentation parameters and a manifest. An actual graphical editor edit/save/reopen is a separate qualification. +The complete [portable design specification](visual-design-spec.md) and `assets/diagram-style/design-tokens.json` preserve broader selected directions, conditional ranges and open choices. They are design inputs, not a runtime support matrix or the smaller `--profile` schema. + ## Selected direction and provisional parameters The bundled profile retains Graphite and signal palette03 and Inter titles with Space Grotesk body text. Its eight color families and four neutrals are reusable presentation values. They establish no global relation, status, risk, type or acceptance semantics. Orange on violet is an optional scoped presentation pairing, not a semantic classification. From 559a89023a8c286c31f4070054c23ed873d8988b Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:08:58 +0000 Subject: [PATCH 06/10] Lead function-tree presentations with native names --- docs/visual-presentation.md | 2 +- scripts/sysml_present.py | 3 +- tests/test_visual_projection.py | 75 +++++++++++++++++++++++++++++---- verifier/visual_projection.py | 26 +++++++----- 4 files changed, 84 insertions(+), 22 deletions(-) diff --git a/docs/visual-presentation.md b/docs/visual-presentation.md index 96e25df..1048ad8 100644 --- a/docs/visual-presentation.md +++ b/docs/visual-presentation.md @@ -8,7 +8,7 @@ This experimental slice renders one checked documentary function tree from the e python3 scripts/sysml_present.py examples/sysml/views/sample-handling.sysml --view FT --output work/tree-paper --body paper --target-width 1200 ``` -Use the CLI help for the current flags. The output directory must be new. The native parser and view checks must pass before any diagram is emitted. A function tree is an explicit documentary decomposition; it is not an activity sequence, allocation, native port network or simulation. The full native element identity and exact parent link accompany each displayed action name. +Use the CLI help for the current flags. The output directory must be new. The native parser and view checks must pass before any diagram is emitted. A function tree is an explicit documentary decomposition; it is not an activity sequence, allocation, native port network or simulation. The drawing leads with exact native action names and the human title “Function decomposition”. Internal view/node IDs and level tags are hidden by default; add `--show-ids` to display the view ID and place node metadata after each native name. Legitimate identifiers within native names are retained verbatim. Full IDs, native bindings, levels and exact parent links remain in the semantic ledger, source companions and manifest provenance in either mode. The subtitle “Documentary function hierarchy” labels this simplified presentation; the full claim limits remain in its companions and manifest. Edit the authoritative SysML and regenerate into another new directory. Do not patch exported SVG to change the engineering model. The output includes editable SVG text, an offline HTML reader, a semantic ledger, presentation parameters and a manifest. An actual graphical editor edit/save/reopen is a separate qualification. diff --git a/scripts/sysml_present.py b/scripts/sysml_present.py index c90f34a..a9e4025 100644 --- a/scripts/sysml_present.py +++ b/scripts/sysml_present.py @@ -18,11 +18,12 @@ def main(): parser.add_argument('--profile', help='validated presentation style JSON') parser.add_argument('--body', choices=('paper', 'white', 'tint'), default='paper') parser.add_argument('--target-width', type=int, default=1200) + parser.add_argument('--show-ids', action='store_true', help='show view ID and node IDs/levels after native names (hidden by default)') args = parser.parse_args() try: report = write_presentation(args.model, args.output, view_id=args.view, profile=load_profile(args.profile), body=args.body, - target_width=args.target_width) + target_width=args.target_width, show_ids=args.show_ids) except PresentationError as exc: report = exc.report except (OSError, ValueError, KeyError) as exc: diff --git a/tests/test_visual_projection.py b/tests/test_visual_projection.py index 8c1a8e6..3ae73db 100644 --- a/tests/test_visual_projection.py +++ b/tests/test_visual_projection.py @@ -58,7 +58,7 @@ def test_exact_identity_text_xml_and_determinism(self): def test_parsed_svg_preserves_title_body_roles_and_square_containers(self): nodes, inventory = fixture() - root = ET.fromstring(render_function_tree(project(nodes, inventory))[0]) + root = ET.fromstring(render_function_tree(project(nodes, inventory), show_ids=True)[0]) namespace = {'svg': 'http://www.w3.org/2000/svg'} title, annotation = root.findall('svg:text', namespace) self.assertEqual(title.attrib['font-family'], 'Inter') @@ -66,7 +66,7 @@ def test_parsed_svg_preserves_title_body_roles_and_square_containers(self): groups = root.findall('svg:g', namespace) self.assertEqual(len(groups), len(nodes)) for group in groups: - metadata, native_label = group.findall('svg:text', namespace) + native_label, metadata = group.findall('svg:text', namespace) self.assertIn('level', metadata.text) self.assertEqual(metadata.attrib['font-family'], 'Space Grotesk') self.assertEqual(native_label.attrib['font-family'], 'Space Grotesk') @@ -82,7 +82,7 @@ def test_profile_sizes_keep_line_spacing_containment_and_parent_endpoints(self): with self.subTest(body_size=size): profile = load_profile() profile['numeric_proposal']['body_size'] = size - svg, ledger = render_function_tree(projection, profile=profile, target_width=2200) + svg, ledger = render_function_tree(projection, profile=profile, target_width=2200, show_ids=True) root = ET.fromstring(svg) boxes = {} for group in root.findall('svg:g', namespace): @@ -90,8 +90,8 @@ def test_profile_sizes_keep_line_spacing_containment_and_parent_endpoints(self): rect = group.find('svg:rect', namespace) box = {k: float(rect.attrib[k]) for k in ('x', 'y', 'width', 'height')} boxes[key] = box - metadata, label = group.findall('svg:text', namespace) - top_baseline, bottom_baseline = float(metadata.attrib['y']), float(label.attrib['y']) + label, metadata = group.findall('svg:text', namespace) + top_baseline, bottom_baseline = float(label.attrib['y']), float(metadata.attrib['y']) self.assertEqual(float(metadata.attrib['font-size']), size) self.assertEqual(float(label.attrib['font-size']), size) self.assertGreaterEqual(bottom_baseline - top_baseline, 1.5 * size) @@ -128,8 +128,8 @@ def test_wide_view_and_metadata_ids_use_selected_font_size_bounds(self): profile = load_profile() profile['numeric_proposal'].update(title_size=title_size, body_size=body_size) with self.assertRaisesRegex(ValueError, 'Target too small'): - render_function_tree(projection, profile=profile, target_width=2200) - svg, ledger = render_function_tree(projection, profile=profile, target_width=7200) + render_function_tree(projection, profile=profile, target_width=2200, show_ids=True) + svg, ledger = render_function_tree(projection, profile=profile, target_width=7200, show_ids=True) tree = ET.fromstring(svg) title = tree.find('{http://www.w3.org/2000/svg}text') self.assertEqual(title.text, view_id + ' - Function decomposition') @@ -142,6 +142,36 @@ def test_wide_view_and_metadata_ids_use_selected_font_size_bounds(self): len(text.text) * 1.5 * body_size) self.assertIn(view_id, svg) + def test_default_names_and_opt_in_metadata_preserve_semantics(self): + nodes, inventory = fixture() + # A legitimate native engineering identifier must not be stripped. + inventory['actions'][0]['name'] = 'F0 Pump' + projection = project(nodes, inventory) + before = deepcopy(projection) + ns = {'svg': 'http://www.w3.org/2000/svg'} + for show_ids in (False, True): + with self.subTest(show_ids=show_ids): + svg, ledger = render_function_tree(projection, show_ids=show_ids) + tree = ET.fromstring(svg) + title, subtitle = tree.findall('svg:text', ns) + self.assertEqual(title.text, ('FT - ' if show_ids else '') + 'Function decomposition') + self.assertEqual(subtitle.text, 'Documentary function hierarchy') + for group, node in zip(tree.findall('svg:g', ns), projection['nodes']): + labels = group.findall('svg:text', ns) + self.assertEqual(labels[0].text, node['text']) + self.assertEqual(len(labels), 2 if show_ids else 1) + if show_ids: + self.assertEqual(labels[1].text, f"{node['id']} · level {node['level']}") + self.assertEqual(group.attrib['data-id'], node['id']) + self.assertEqual(group.attrib['data-level'], str(node['level'])) + self.assertEqual(group.attrib['data-element'], node['element']) + self.assertEqual(ledger['show_ids'], show_ids) + self.assertIn('no behavioral or engineering acceptance claim', ledger['claim_scope']) + self.assertEqual(projection, before) + for invalid in ('false', 1, None): + with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, 'boolean'): + render_function_tree(projection, show_ids=invalid) + def test_invalid_structure_bindings_and_requested_views_refused(self): for defect in ('cycle', 'orphan', 'level', 'binding', 'duplicate', 'missing', 'kind'): nodes, inventory = fixture() @@ -219,6 +249,33 @@ def test_native_offline_hashes_sources_exclusive_output_and_regeneration(self): self.assertNotEqual(failed['status'], 'pass') self.assertFalse((temp / 'bad').exists()) + def test_cli_default_and_opt_in_visibility_keep_exact_ledger_and_sources(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: + ledgers = [] + for show_ids in (False, True): + output = Path(temp) / str(show_ids) + command = [sys.executable, str(ROOT / 'scripts/sysml_present.py'), str(MODEL), + '--view', 'FT', '--output', str(output), '--target-width', '1600'] + if show_ids: + command.append('--show-ids') + result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + tree = ET.parse(output / 'view.svg') + texts = [t.text for t in tree.findall('.//{http://www.w3.org/2000/svg}text')] + self.assertEqual(texts[:2], [('FT - ' if show_ids else '') + 'Function decomposition', + 'Documentary function hierarchy']) + self.assertIn('ProcessSample', texts) + self.assertEqual('F0 · level 0' in texts, show_ids) + self.assertEqual((output / 'sources/model-1.sysml').read_bytes(), MODEL.read_bytes()) + ledgers.append((output / 'semantic-ledger.json').read_bytes()) + manifest = json.loads((output / 'manifest.json').read_text()) + self.assertEqual(manifest['show_ids'], show_ids) + self.assertIn('no behavioral or engineering acceptance claim', manifest['claim_scope']) + self.assertIn('no behavioral or engineering acceptance claim', + (output / 'index.html').read_text()) + self.assertEqual(*ledgers) + def test_native_cli_accepts_nondefault_profile_and_preserves_provisional_status(self): (ROOT / '.tmp').mkdir(exist_ok=True) with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: @@ -255,10 +312,10 @@ def test_native_wide_title_refuses_small_target_without_output(self): profile['numeric_proposal'].update(title_size=48, body_size=24) small = temp / 'too-small' with self.assertRaisesRegex(ValueError, 'Target too small'): - write_presentation(source, small, view_id=view_id, profile=profile, target_width=2200) + write_presentation(source, small, view_id=view_id, profile=profile, target_width=2200, show_ids=True) self.assertFalse(small.exists()) wide = temp / 'wide' - result = write_presentation(source, wide, view_id=view_id, profile=profile, target_width=7200) + result = write_presentation(source, wide, view_id=view_id, profile=profile, target_width=7200, show_ids=True) self.assertEqual(result['status'], 'pass') title = ET.parse(wide / 'view.svg').find('{http://www.w3.org/2000/svg}text') self.assertEqual(title.text, view_id + ' - Function decomposition') diff --git a/verifier/visual_projection.py b/verifier/visual_projection.py index 4d528ab..4d577f3 100644 --- a/verifier/visual_projection.py +++ b/verifier/visual_projection.py @@ -55,8 +55,10 @@ def project_function_tree(inventory, view_id): "nodes": nodes, "view_checks": checks} -def render_function_tree(projection, *, profile=None, body="paper", target_width=1200): +def render_function_tree(projection, *, profile=None, body="paper", target_width=1200, show_ids=False): """Render an arrow-free indented tree; preserve labels as literal XML text.""" + if type(show_ids) is not bool: + raise ValueError("show_ids must be a boolean") if type(target_width) not in (int, float) or not math.isfinite(target_width) or not 320 <= target_width <= 8192: raise ValueError("Target width must be between 320 and 8192 pixels") # Recheck the public structural contract even when called without project(). @@ -86,12 +88,12 @@ def render_function_tree(projection, *, profile=None, body="paper", target_width # Qualified printable glyphs get a conservative 1.5-em advance bound; # actual loaded glyph bounds remain a separate browser inspection gate. style = presentation_style(profile, body=body, target_width=1200, canvas_width=1200) - title_text = projection["id"] + " - Function decomposition" + title_text = (projection["id"] + " - " if show_ids else "") + "Function decomposition" body_advance = 1.5 * style["body_size"] title_advance = 1.5 * style["title_size"] width = math.ceil(max(1200, 96 + len(title_text) * title_advance, *(48 + n["level"] * 48 + 40 + 48 + body_advance * max( - len(n["text"]), len(f"{n['id']} · level {n['level']}")) for n in nodes))) + len(n["text"]), len(f"{n['id']} · level {n['level']}") if show_ids else 0) for n in nodes))) # Reapply the readable-label gate to the complete calculated canvas. style = presentation_style(profile, body=body, target_width=target_width, canvas_width=width) # Conservative line boxes depend on the qualified profile size. Browser @@ -105,12 +107,12 @@ def render_function_tree(projection, *, profile=None, body="paper", target_width height = 112 + len(nodes) * row_stride + 16 positions = {key: (48 + by_id[key]["level"] * 48, 112 + i * row_stride) for i, key in enumerate(order)} svg = [f'', - f'{escape(projection["id"])} - function decomposition', + f'{escape(title_text)}', f'{escape(CLAIM)}', '', f'', f'{escape(title_text)}', - f'Documentary parent links · no behavioral or engineering acceptance claim'] + f'Documentary function hierarchy'] for key in order: node = by_id[key] if node['parent'] is not None: @@ -124,12 +126,14 @@ def render_function_tree(projection, *, profile=None, body="paper", target_width box_width = width - x - 48 attrs = ' '.join(f'data-{k}="{escape(str(node[k]), quote=True)}"' for k in ("id", "element", "level", "parent")) svg.extend([f'', f'', - f'{key} · level {node["level"]}', - f'{escape(node["text"])}', '']) + f'{escape(node["text"])}']) + if show_ids: + svg.append(f'{escape(key)} · level {node["level"]}') + svg.append('
') geometry.append({"id": key, "x": x, "y": y, "width": box_width, "height": node_height}) svg.append('') ledger = {"version": VERSION, "body": body, "target_width": target_width, "canvas_width": width, - "canvas_height": height, "claim_scope": CLAIM, + "canvas_height": height, "claim_scope": CLAIM, "show_ids": show_ids, "layout": {"line_height": line_height, "node_height": node_height, "row_stride": row_stride, "padding": padding, "first_baseline": first_baseline, "second_baseline": second_baseline, @@ -137,7 +141,7 @@ def render_function_tree(projection, *, profile=None, body="paper", target_width return '\n'.join(svg) + '\n', ledger -def write_presentation(model_paths, output, *, view_id, profile=None, body="paper", target_width=1200): +def write_presentation(model_paths, output, *, view_id, profile=None, body="paper", target_width=1200, show_ids=False): """Acquire a native inventory and write a new, independently editable bundle.""" output = Path(output) if output.exists() or output.is_symlink(): @@ -148,7 +152,7 @@ def write_presentation(model_paths, output, *, view_id, profile=None, body="pape return report projection = project_function_tree(report["inventory"], view_id) validated_profile = load_profile(profile) if profile is None or isinstance(profile, (str, Path)) else deepcopy(profile) - svg, presentation = render_function_tree(projection, profile=validated_profile, body=body, target_width=target_width) + svg, presentation = render_function_tree(projection, profile=validated_profile, body=body, target_width=target_width, show_ids=show_ids) # Capture source bytes and verify parser pins before creating any output. sources = {} for path in paths: @@ -174,7 +178,7 @@ def write_presentation(model_paths, output, *, view_id, profile=None, body="pape companion = f"sources/model-{i+1}.sysml" files[companion] = raw source_manifest.append({"file": companion, "name": Path(path).name, "sha256": _digest(raw)}) - manifest = {"version": VERSION, "view_id": view_id, "claim_scope": CLAIM, + manifest = {"version": VERSION, "view_id": view_id, "claim_scope": CLAIM, "show_ids": show_ids, "model_sha256": report["model_sha256"], "parser": report["parser"], "checker_version": report["checker_version"], "sources": source_manifest, "style_sha256": _digest(_json(validated_profile).encode()), From 823ef2d1e73ca281834c16503e104914f8111efc Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:44:58 +0000 Subject: [PATCH 07/10] Bind explicit presentation labels to native function identities --- .../views/field-service-demo.labels.json | 59 ++++++++ examples/sysml/views/field-service-demo.sysml | 109 +++++++++++++++ scripts/check.py | 2 +- tests/test_visual_labels.py | 129 ++++++++++++++++++ verifier/visual_labels.py | 71 ++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 examples/sysml/views/field-service-demo.labels.json create mode 100644 examples/sysml/views/field-service-demo.sysml create mode 100644 tests/test_visual_labels.py create mode 100644 verifier/visual_labels.py diff --git a/examples/sysml/views/field-service-demo.labels.json b/examples/sysml/views/field-service-demo.labels.json new file mode 100644 index 0000000..79d78c5 --- /dev/null +++ b/examples/sysml/views/field-service-demo.labels.json @@ -0,0 +1,59 @@ +{ + "version": 1, + "view": "FT_DEMO", + "title": "Maintain the inspection station", + "labels": [ + { + "element": "FieldServiceDemo::MaintainStation", + "text": "Maintain the inspection station" + }, + { + "element": "FieldServiceDemo::PrepareServiceVisit", + "text": "Prepare the service visit and station access" + }, + { + "element": "FieldServiceDemo::ReviewServiceRequest", + "text": "Review the reported symptoms and service request" + }, + { + "element": "FieldServiceDemo::ConfirmAccessReadiness", + "text": "Confirm station access and workspace readiness" + }, + { + "element": "FieldServiceDemo::GatherServiceResources", + "text": "Gather reference information and service tools" + }, + { + "element": "FieldServiceDemo::InspectAndRestoreStation", + "text": "Inspect the station and restore its working condition" + }, + { + "element": "FieldServiceDemo::InspectStationCondition", + "text": "Inspect station components and record observations" + }, + { + "element": "FieldServiceDemo::InvestigateReportedIssue", + "text": "Investigate the reported issue using observed evidence" + }, + { + "element": "FieldServiceDemo::RestoreAffectedComponents", + "text": "Restore affected components within the service scope" + }, + { + "element": "FieldServiceDemo::ReturnStationAndReport", + "text": "Return the station and communicate service findings" + }, + { + "element": "FieldServiceDemo::ReviewPostServiceCondition", + "text": "Review the station condition after the service work" + }, + { + "element": "FieldServiceDemo::ReturnStationToCustodian", + "text": "Return the station and explain any remaining concerns" + }, + { + "element": "FieldServiceDemo::DocumentServiceFindings", + "text": "Document completed work and unresolved observations" + } + ] +} diff --git a/examples/sysml/views/field-service-demo.sysml b/examples/sysml/views/field-service-demo.sysml new file mode 100644 index 0000000..418792f --- /dev/null +++ b/examples/sysml/views/field-service-demo.sysml @@ -0,0 +1,109 @@ +package FieldServiceDemo { + doc /* Original synthetic station-maintenance decomposition for presentation testing. + Documentary parentage only: no requirements, acceptance status, or execution order. */ + action def MaintainStation; + action def PrepareServiceVisit; + action def ReviewServiceRequest; + action def ConfirmAccessReadiness; + action def GatherServiceResources; + action def InspectAndRestoreStation; + action def InspectStationCondition; + action def InvestigateReportedIssue; + action def RestoreAffectedComponents; + action def ReturnStationAndReport; + action def ReviewPostServiceCondition; + action def ReturnStationToCustodian; + action def DocumentServiceFindings; + + doc cseViews /* + CSE-VIEWS/1.0 +{ + "views": [ + { + "id": "FT_DEMO", + "kind": "function_tree", + "nodes": [ + { + "id": "F0", + "element": "FieldServiceDemo::MaintainStation", + "level": 0, + "parent": null + }, + { + "id": "F1", + "element": "FieldServiceDemo::PrepareServiceVisit", + "level": 1, + "parent": "F0" + }, + { + "id": "F11", + "element": "FieldServiceDemo::ReviewServiceRequest", + "level": 2, + "parent": "F1" + }, + { + "id": "F12", + "element": "FieldServiceDemo::ConfirmAccessReadiness", + "level": 2, + "parent": "F1" + }, + { + "id": "F13", + "element": "FieldServiceDemo::GatherServiceResources", + "level": 2, + "parent": "F1" + }, + { + "id": "F2", + "element": "FieldServiceDemo::InspectAndRestoreStation", + "level": 1, + "parent": "F0" + }, + { + "id": "F21", + "element": "FieldServiceDemo::InspectStationCondition", + "level": 2, + "parent": "F2" + }, + { + "id": "F22", + "element": "FieldServiceDemo::InvestigateReportedIssue", + "level": 2, + "parent": "F2" + }, + { + "id": "F23", + "element": "FieldServiceDemo::RestoreAffectedComponents", + "level": 2, + "parent": "F2" + }, + { + "id": "F3", + "element": "FieldServiceDemo::ReturnStationAndReport", + "level": 1, + "parent": "F0" + }, + { + "id": "F31", + "element": "FieldServiceDemo::ReviewPostServiceCondition", + "level": 2, + "parent": "F3" + }, + { + "id": "F32", + "element": "FieldServiceDemo::ReturnStationToCustodian", + "level": 2, + "parent": "F3" + }, + { + "id": "F33", + "element": "FieldServiceDemo::DocumentServiceFindings", + "level": 2, + "parent": "F3" + } + ] + } + ] +} + */ +} diff --git a/scripts/check.py b/scripts/check.py index 291a9c6..8f221b8 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -43,7 +43,7 @@ def main(): suites = ("test_develop_conops.py", "test_scenario_split.py", "test_concept.py", "test_verifier.py", "test_language.py", "test_language_oracle.py", "test_influence.py", "test_events.py", "test_public_language_cases.py", "test_sysml.py", "test_sysml_views.py", - "test_sysml_public.py", "test_skill_runtime.py", "test_engineering_views.py", "test_visual_style.py", "test_visual_projection.py") + "test_sysml_public.py", "test_skill_runtime.py", "test_engineering_views.py", "test_visual_style.py", "test_visual_projection.py", "test_visual_labels.py") for suite in suites: result = subprocess.run([sys.executable, "-B", str(ROOT / "tests" / suite)], cwd=ROOT, env=environment, text=True, diff --git a/tests/test_visual_labels.py b/tests/test_visual_labels.py new file mode 100644 index 0000000..e24473b --- /dev/null +++ b/tests/test_visual_labels.py @@ -0,0 +1,129 @@ +"""Presentation wording remains explicit, native-bound, and separate from models.""" +from copy import deepcopy +import json +from pathlib import Path +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from verifier.visual_labels import apply_labels, load_labels +from verifier.sysml import CLASSES, JAR, check_sysml + +MODEL = ROOT / 'examples/sysml/views/field-service-demo.sysml' +LABELS = MODEL.with_suffix('.labels.json') + + +def projection(): + return {'id': 'FT', 'kind': 'function_tree', 'nodes': [ + {'id': 'F0', 'element': 'Example::MaintainStation', 'text': 'MaintainStation', 'level': 0, 'parent': None}, + {'id': 'F1', 'element': 'Example::ReviewServiceRequest', 'text': 'ReviewServiceRequest', 'level': 1, 'parent': 'F0'}]} + + +def record(): + return {'version': 1, 'view': 'FT', 'title': 'Maintain the station', + 'labels': [{'element': 'Example::MaintainStation', 'text': 'Maintain the station'}]} + + +class VisualLabelTests(unittest.TestCase): + def test_native_default_and_partial_explicit_wording_preserve_projection(self): + model = projection() + prior = deepcopy(model) + self.assertIsNone(load_labels()) + default = apply_labels(model) + self.assertEqual(default, {'title': 'Function decomposition', 'labels': { + 'F0': 'MaintainStation', 'F1': 'ReviewServiceRequest'}, 'bindings': []}) + explicit = record() + saved = deepcopy(explicit) + result = apply_labels(model, explicit) + self.assertEqual(result['title'], 'Maintain the station') + self.assertEqual(result['labels'], {'F0': 'Maintain the station', 'F1': 'ReviewServiceRequest'}) + self.assertEqual(result['bindings'], explicit['labels']) + self.assertEqual(model, prior) + self.assertEqual(explicit, saved) + result['bindings'][0]['text'] = 'Changed output' + self.assertEqual(explicit, saved) + self.assertEqual(model, prior) + + def test_load_is_strict_and_rejects_unsupported_or_blank_wording(self): + for change in ( + {'version': True}, {'version': 2}, {'view': 'wrong view'}, {'title': ''}, {'title': ' '}, + {'title': 'Unsupported \u2603'}, {'title': 'Line\nbreak'}, {'labels': {}}, + {'labels': [{'element': 'Example::MaintainStation', 'text': ' '}]}, + {'labels': [{'element': 'Example::MaintainStation', 'text': 'New text', 'status': 'accepted'}]}, + {'labels': [{'element': '', 'text': 'New text'}]}, {'authority': 'accepted'}, + ): + with self.subTest(change=change): + candidate = record() + candidate.update(change) + with self.assertRaises(ValueError): load_labels(candidate) + for field in record(): + candidate = record() + candidate.pop(field) + with self.subTest(missing=field), self.assertRaises(ValueError): load_labels(candidate) + candidate = record() + candidate['title'] = 'Inspect · restore' + self.assertEqual(load_labels(candidate)['title'], 'Inspect · restore') + + def test_native_bindings_cannot_target_other_views_or_unknown_or_repeated_elements(self): + model = projection() + other = record() + other['view'] = 'Other' + with self.assertRaisesRegex(ValueError, 'different view'): apply_labels(model, other) + unknown = record() + unknown['labels'][0]['element'] = 'Example::Absent' + with self.assertRaisesRegex(ValueError, 'outside'): apply_labels(model, unknown) + repeated = record() + repeated['labels'].append(deepcopy(repeated['labels'][0])) + with self.assertRaisesRegex(ValueError, 'Repeated'): apply_labels(model, repeated) + for field in ('id', 'element'): + ambiguous = projection() + ambiguous['nodes'][1][field] = ambiguous['nodes'][0][field] + with self.subTest(field=field), self.assertRaisesRegex(ValueError, 'unique'): + apply_labels(ambiguous, record()) + + def test_file_loading_duplicate_keys_and_no_source_code_evaluation(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: + path = Path(temp) / 'labels.json' + path.write_text(json.dumps(record())) + self.assertEqual(load_labels(path), record()) + for content in ( + '{"version":1,"version":1,"view":"FT","title":"Title","labels":[]}', + '{"version":1,"view":"FT","title":"Title","labels":[{"element":"X","text":"A","text":"B"}]}', + '{"version":NaN,"view":"FT","title":"Title","labels":[]}', + ): + path.write_text(content) + with self.subTest(content=content), self.assertRaises(ValueError): load_labels(path) + candidate = record() + candidate['labels'][0]['text'] = "__import__('os').system('false')" + path.write_text(json.dumps(candidate)) + self.assertEqual(apply_labels(projection(), path)['labels']['F0'], candidate['labels'][0]['text']) + + +@unittest.skipUnless(JAR.is_file() and (CLASSES / 'SysmlBridge.class').is_file(), 'Pinned native parser unavailable') +class NativeLabelFixtureTests(unittest.TestCase): + def test_original_demo_binds_thirteen_native_actions_and_exact_three_level_tree(self): + report = check_sysml(MODEL, syntax_only=True) + self.assertEqual(report['status'], 'pass', report) + inventory = report['inventory'] + from verifier.visual_projection import project_function_tree + model = project_function_tree(inventory, 'FT_DEMO') + prior = deepcopy(model) + self.assertEqual(len(model['nodes']), 13) + self.assertEqual([sum(n['level'] == level for n in model['nodes']) for level in (0, 1, 2)], [1, 3, 9]) + for branch in (n for n in model['nodes'] if n['level'] == 1): + self.assertEqual(sum(n['parent'] == branch['id'] for n in model['nodes']), 3) + labels = load_labels(LABELS) + result = apply_labels(model, labels) + self.assertEqual(len(result['bindings']), 13) + self.assertEqual(set(result['labels']), {n['id'] for n in model['nodes']}) + self.assertEqual(result['title'], 'Maintain the inspection station') + self.assertEqual(apply_labels(model)['labels']['F0'], 'MaintainStation') + self.assertEqual(model, prior) + self.assertTrue(all(' ' in text for text in result['labels'].values())) + + +if __name__ == '__main__': + unittest.main() diff --git a/verifier/visual_labels.py b/verifier/visual_labels.py new file mode 100644 index 0000000..c82b521 --- /dev/null +++ b/verifier/visual_labels.py @@ -0,0 +1,71 @@ +"""Explicit, native-element-bound presentation wording; never model renaming.""" +from copy import deepcopy +import json +from pathlib import Path + +from .engineering_views import ID +from .visual_style import require_supported_text + + +def _unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate label JSON key: {key}") + result[key] = value + return result + + +def load_labels(value=None): + """Validate a label dictionary or JSON file; None selects exact native names.""" + if value is None: + return None + if isinstance(value, (str, Path)): + value = json.loads(Path(value).read_text(), object_pairs_hook=_unique, + parse_constant=lambda text: (_ for _ in ()).throw(ValueError(f"Nonfinite {text}"))) + if not isinstance(value, dict) or set(value) != {"version", "view", "title", "labels"}: + raise ValueError("Labels need exactly version, view, title and labels") + if type(value['version']) is not int or value['version'] != 1: + raise ValueError("Expected presentation labels version 1") + if not isinstance(value['view'], str) or not ID.fullmatch(value['view']): + raise ValueError("Labels need a supported view ID") + require_supported_text(value['title']) + if not value['title'].strip() or not isinstance(value['labels'], list): + raise ValueError("Labels need a nonempty title and a labels list") + seen = set() + for label in value['labels']: + if not isinstance(label, dict) or set(label) != {'element', 'text'}: + raise ValueError("Each display label needs exactly element and text") + for field in ('element', 'text'): + require_supported_text(label[field]) + if not label[field].strip(): + raise ValueError(f"Display label {field} cannot be blank") + if label['element'] in seen: + raise ValueError("Repeated display label native element: " + label['element']) + seen.add(label['element']) + return deepcopy(value) + + +def apply_labels(projection, value=None): + """Return separate wording, preserving the checked projection byte for byte.""" + record = load_labels(value) + if record is not None and record['view'] != projection['id']: + raise ValueError("Display labels select a different view") + nodes = projection['nodes'] + by_element, node_ids, labels = {}, set(), {} + for node in nodes: + element, node_id = node['element'], node['id'] + if element in by_element or node_id in node_ids: + raise ValueError("Presentation requires unique native bindings and node IDs") + require_supported_text(element) + require_supported_text(node['text']) + by_element[element] = node_id + node_ids.add(node_id) + labels[node_id] = node['text'] + bindings = [] if record is None else record['labels'] + for binding in bindings: + if binding['element'] not in by_element: + raise ValueError("Display label binds outside the selected native view: " + binding['element']) + labels[by_element[binding['element']]] = binding['text'] + return {'title': 'Function decomposition' if record is None else record['title'], + 'labels': labels, 'bindings': deepcopy(bindings)} From ea19642aa363e1891e94f060fe2c5d28a7de4ec0 Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:44:58 +0000 Subject: [PATCH 08/10] Render compact measured function hierarchies with scoped palette roles --- assets/diagram-style/fonts/metrics.json | 415 ++++++++++++++++++++++++ docs/visual-presentation.md | 29 +- scripts/sysml_present.py | 3 +- tests/test_visual_projection.py | 235 ++++++++------ tests/test_visual_style.py | 11 +- verifier/visual_projection.py | 245 +++++++++----- verifier/visual_style.py | 48 +++ 7 files changed, 811 insertions(+), 175 deletions(-) create mode 100644 assets/diagram-style/fonts/metrics.json diff --git a/assets/diagram-style/fonts/metrics.json b/assets/diagram-style/fonts/metrics.json new file mode 100644 index 0000000..91473e6 --- /dev/null +++ b/assets/diagram-style/fonts/metrics.json @@ -0,0 +1,415 @@ +{ + "version": 1, + "method": "Canvas measureText at 1000px; font-kerning none; no ligatures; printable ASCII plus middle dot", + "renderer": "143.0.7499.4", + "faces": { + "Inter:400": { + "font_sha256": "8909904ab6c872eb994093482a88a28eca2cd95912d7b6fecd72103b0dc07edc", + "units": 1000, + "advances": { + "0": 631, + "1": 407, + "2": 610, + "3": 618, + "4": 646, + "5": 593, + "6": 620, + "7": 566, + "8": 619, + "9": 620, + " ": 281, + "!": 288, + "\"": 466, + "#": 633, + "$": 642, + "%": 982, + "&": 644, + "'": 300, + "(": 365, + ")": 365, + "*": 501, + "+": 662, + ",": 288, + "-": 460, + ".": 288, + "/": 360, + ":": 288, + ";": 302, + "<": 662, + "=": 662, + ">": 662, + "?": 511, + "@": 966, + "A": 690, + "B": 654, + "C": 730, + "D": 722, + "E": 601, + "F": 590, + "G": 746, + "H": 743, + "I": 269, + "J": 571, + "K": 672, + "L": 565, + "M": 903, + "N": 753, + "O": 765, + "P": 639, + "Q": 765, + "R": 644, + "S": 642, + "T": 646, + "U": 744, + "V": 690, + "W": 985, + "X": 682, + "Y": 679, + "Z": 629, + "[": 365, + "\\": 360, + "]": 365, + "^": 471, + "_": 456, + "`": 323, + "a": 562, + "b": 612, + "c": 571, + "d": 612, + "e": 583, + "f": 370, + "g": 613, + "h": 591, + "i": 242, + "j": 242, + "k": 549, + "l": 242, + "m": 876, + "n": 591, + "o": 600, + "p": 612, + "q": 612, + "r": 376, + "s": 528, + "t": 327, + "u": 591, + "v": 562, + "w": 818, + "x": 546, + "y": 562, + "z": 552, + "{": 426, + "|": 333, + "}": 426, + "~": 662, + "·": 288 + } + }, + "Inter:700": { + "font_sha256": "6f56409fd3d64bb85f7d070bce20749db2d66b6d63cec586cc22d1c761be2491", + "units": 1000, + "advances": { + "0": 674, + "1": 431, + "2": 630, + "3": 646, + "4": 676, + "5": 622, + "6": 649, + "7": 582, + "8": 651, + "9": 649, + " ": 237, + "!": 338, + "\"": 552, + "#": 649, + "$": 655, + "%": 1016, + "&": 672, + "'": 339, + "(": 377, + ")": 377, + "*": 559, + "+": 679, + ",": 334, + "-": 468, + ".": 334, + "/": 388, + ":": 334, + ";": 343, + "<": 679, + "=": 679, + ">": 679, + "?": 560, + "@": 1016, + "A": 747, + "B": 662, + "C": 740, + "D": 722, + "E": 607, + "F": 587, + "G": 750, + "H": 747, + "I": 281, + "J": 584, + "K": 719, + "L": 565, + "M": 932, + "N": 762, + "O": 771, + "P": 648, + "Q": 777, + "R": 657, + "S": 655, + "T": 667, + "U": 732, + "V": 747, + "W": 1038, + "X": 738, + "Y": 731, + "Z": 664, + "[": 377, + "\\": 388, + "]": 377, + "^": 487, + "_": 476, + "`": 365, + "a": 581, + "b": 630, + "c": 588, + "d": 630, + "e": 596, + "f": 398, + "g": 632, + "h": 623, + "i": 271, + "j": 271, + "k": 580, + "l": 271, + "m": 913, + "n": 623, + "o": 613, + "p": 630, + "q": 630, + "r": 407, + "s": 560, + "t": 366, + "u": 623, + "v": 600, + "w": 850, + "x": 580, + "y": 602, + "z": 573, + "{": 469, + "|": 372, + "}": 469, + "~": 679, + "·": 334 + } + }, + "Space Grotesk:400": { + "font_sha256": "65fd17fcbd2e2f522940b5f67ead3d23329e02891aa5495e74d11a499c0b0673", + "units": 1000, + "advances": { + "0": 641, + "1": 418, + "2": 603, + "3": 604, + "4": 625, + "5": 599, + "6": 612, + "7": 564, + "8": 623, + "9": 612, + " ": 257, + "!": 253, + "\"": 425, + "#": 628, + "$": 613, + "%": 772, + "&": 604, + "'": 251, + "(": 379, + ")": 377, + "*": 520, + "+": 620, + ",": 251, + "-": 455, + ".": 253, + "/": 370, + ":": 253, + ";": 253, + "<": 620, + "=": 620, + ">": 620, + "?": 580, + "@": 1026, + "A": 630, + "B": 661, + "C": 642, + "D": 663, + "E": 563, + "F": 540, + "G": 660, + "H": 656, + "I": 244, + "J": 594, + "K": 610, + "L": 546, + "M": 865, + "N": 664, + "O": 669, + "P": 601, + "Q": 669, + "R": 631, + "S": 613, + "T": 588, + "U": 666, + "V": 619, + "W": 875, + "X": 638, + "Y": 610, + "Z": 577, + "[": 335, + "\\": 370, + "]": 335, + "^": 620, + "_": 620, + "`": 280, + "a": 575, + "b": 644, + "c": 598, + "d": 644, + "e": 592, + "f": 442, + "g": 642, + "h": 613, + "i": 245, + "j": 247, + "k": 538, + "l": 245, + "m": 860, + "n": 613, + "o": 618, + "p": 644, + "q": 644, + "r": 377, + "s": 527, + "t": 460, + "u": 612, + "v": 539, + "w": 801, + "x": 593, + "y": 612, + "z": 521, + "{": 432, + "|": 240, + "}": 432, + "~": 620, + "·": 187 + } + }, + "Space Grotesk:700": { + "font_sha256": "35f8aec56cfd5cbfdb03cc68733a54a0b05bb3617ffcd5fd332badc0b045ca55", + "units": 1000, + "advances": { + "0": 648, + "1": 452, + "2": 594, + "3": 608, + "4": 636, + "5": 600, + "6": 618, + "7": 554, + "8": 600, + "9": 618, + " ": 254, + "!": 298, + "\"": 514, + "#": 636, + "$": 606, + "%": 758, + "&": 591, + "'": 294, + "(": 398, + ")": 390, + "*": 540, + "+": 620, + ",": 294, + "-": 432, + ".": 298, + "/": 388, + ":": 298, + ";": 298, + "<": 620, + "=": 620, + ">": 620, + "?": 578, + "@": 1014, + "A": 634, + "B": 664, + "C": 644, + "D": 666, + "E": 554, + "F": 534, + "G": 662, + "H": 656, + "I": 264, + "J": 610, + "K": 626, + "L": 542, + "M": 882, + "N": 670, + "O": 676, + "P": 604, + "Q": 676, + "R": 632, + "S": 606, + "T": 588, + "U": 672, + "V": 618, + "W": 898, + "X": 644, + "Y": 624, + "Z": 576, + "[": 358, + "\\": 388, + "]": 358, + "^": 620, + "_": 620, + "`": 296, + "a": 578, + "b": 638, + "c": 586, + "d": 638, + "e": 577, + "f": 436, + "g": 638, + "h": 616, + "i": 266, + "j": 268, + "k": 564, + "l": 266, + "m": 854, + "n": 616, + "o": 612, + "p": 638, + "q": 638, + "r": 396, + "s": 524, + "t": 456, + "u": 616, + "v": 548, + "w": 784, + "x": 592, + "y": 616, + "z": 518, + "{": 466, + "|": 258, + "}": 466, + "~": 620, + "·": 218 + } + } + } +} diff --git a/docs/visual-presentation.md b/docs/visual-presentation.md index 1048ad8..0b031f4 100644 --- a/docs/visual-presentation.md +++ b/docs/visual-presentation.md @@ -8,12 +8,37 @@ This experimental slice renders one checked documentary function tree from the e python3 scripts/sysml_present.py examples/sysml/views/sample-handling.sysml --view FT --output work/tree-paper --body paper --target-width 1200 ``` -Use the CLI help for the current flags. The output directory must be new. The native parser and view checks must pass before any diagram is emitted. A function tree is an explicit documentary decomposition; it is not an activity sequence, allocation, native port network or simulation. The drawing leads with exact native action names and the human title “Function decomposition”. Internal view/node IDs and level tags are hidden by default; add `--show-ids` to display the view ID and place node metadata after each native name. Legitimate identifiers within native names are retained verbatim. Full IDs, native bindings, levels and exact parent links remain in the semantic ledger, source companions and manifest provenance in either mode. The subtitle “Documentary function hierarchy” labels this simplified presentation; the full claim limits remain in its companions and manifest. +Use the CLI help for the current flags. The output directory must be new. The native parser and view checks must pass before any diagram is emitted. A function tree is an explicit documentary decomposition; it is not an activity sequence, allocation, native port network or simulation. Without a label companion, the drawing leads with exact native action names and the human title “Function decomposition”. An explicit `--labels` companion supplies separately authored display wording bound to those exact native identities; it never renames the authoritative model. Internal view/node IDs and level tags are hidden by default; add `--show-ids` to display the view ID and place node metadata after each native name. Legitimate identifiers within native names are retained verbatim. Full IDs, native bindings, levels and exact parent links remain in the semantic ledger, source companions and manifest provenance in either mode. The subtitle “Documentary function hierarchy” labels this simplified presentation; the full claim limits remain in its companions and manifest. Edit the authoritative SysML and regenerate into another new directory. Do not patch exported SVG to change the engineering model. The output includes editable SVG text, an offline HTML reader, a semantic ledger, presentation parameters and a manifest. An actual graphical editor edit/save/reopen is a separate qualification. The complete [portable design specification](visual-design-spec.md) and `assets/diagram-style/design-tokens.json` preserve broader selected directions, conditional ranges and open choices. They are design inputs, not a runtime support matrix or the smaller `--profile` schema. + +## Synthetic branching demonstration + +```sh +python3 scripts/sysml_present.py examples/sysml/views/field-service-demo.sysml --view FT_DEMO --labels examples/sysml/views/field-service-demo.labels.json --output work/station-tree --body white --target-width 1600 +``` + +This owned synthetic example declares one root function, three first-level functions and nine leaf functions. Its decomposition describes a service scope, without prescribing execution order, safety procedures, requirements or real equipment behavior. Its author supplied the readable labels explicitly. The native names, IDs, levels, bindings and parentage remain unchanged in `semantic-ledger.json` and exact SysML companions. The canonical `display-labels.json` companion and `presentation.json` record the separate wording. Unknown bindings, duplicate keys or elements, wrong views and unsupported glyphs are refused before output creation. Omitting a binding retains the exact native name; there is no inferred camel-case splitting. + +Nodes wrap within a measured text region and fit their own content plus padding. Branch subtrees are balanced horizontally; leaf siblings stack in compact local groups. Child counts are derived from the documentary tree and occupy a useful separate cell. `--show-ids` adds metadata after the display label. Neither framing nor color creates a model-owned part or compartment. + +| Design rule | Integrated behavior in this slice | Available or later work | +| --- | --- | --- | +| Square content-fit framing | Glyph-advance wrapping, bounded padding and height, no canvas-wide bars | Other notation-specific node shapes | +| Useful partitions and side keys | Derived child-count cell; optional ID/level cell; a single outer frame | Domain-owned compartments | +| Selected palette shade/tint roles | Violet/tangerine root; blue, tangerine and green demo branches; readable ink labels | All eight families remain tokens; no universal status meanings | +| Inter and Space Grotesk | Embedded Inter title and Space Grotesk labels/counts, with pinned measured advances | Wider glyph qualification | +| Target-scale vector geometry | Editable text/SVG; declared display-pixel strokes; fresh vector raster export | Calibrated print and editor roundtrip | +| Line and hatch samples | Documentary decomposition routes only; shared trunks have explicit parents | Dashed relation semantics and typed materials/equipment textures | +| Drafted exterior illustrations | No invented physical geometry in this function view | Source-admitted physical glyph adapters | + +White, paper and faint-tint leaf bodies remain provisional choices; the demonstration requests white explicitly. Branch color is a local navigation role, repeated within each first-level branch. It is neither engineering status nor a selected universal palette mapping. Texture is deliberately absent: this function model declares no material or equipment classes that could give a hatch meaning. + +Text layout uses `fonts/metrics.json`, measured from the pinned WOFF2 faces with Canvas `measureText` at 1000px and tied to each face SHA-256. Advances scale to the selected font size; normal glyph shapes and spaces are retained. This particular layout disables kerning and ligatures consistently in measurement and SVG, and requests geometric-precision text rendering to retain fractional advances at small sizes. It applies no letter spacing, fitting transforms or character substitution. Soft line breaks concatenate to the exact authored string, including spaces. Actual browser glyph bounds, face identity and target-scale clearance still need checking for each qualified candidate; advance sums alone are not visual acceptance. + ## Selected direction and provisional parameters The bundled profile retains Graphite and signal palette03 and Inter titles with Space Grotesk body text. Its eight color families and four neutrals are reusable presentation values. They establish no global relation, status, risk, type or acceptance semantics. Orange on violet is an optional scoped presentation pairing, not a semantic classification. @@ -24,7 +49,7 @@ This slice refuses unsupported characters outside printable ASCII plus middle do ## Reusable boundaries -`verifier.visual_style` validates profile data, embeds exact font bytes/notices, computes opaque-field contrast and converts a proposed display stroke width to native coordinates. `verifier.visual_projection` consumes the published parsed inventory after existing checks, retains model identities and parentage, and emits ordinary vector boxes, exact text and separate parent paths. Shared routes and crossings do not become junctions. +`verifier.visual_style` validates profile data, embeds exact font bytes/notices, computes opaque-field contrast and converts a proposed display stroke width to native coordinates. `verifier.visual_projection` consumes the published parsed inventory after existing checks, retains model identities and parentage, and emits compact square boxes, losslessly wrapped text and documentary parent routes. A shared route is drawn once only for children with the same explicit parent; unrelated crossings never imply a junction. The common fan-out trunk is neutral, with scoped colored branch drops. A narrow first slice does not implement physical glyph semantics, RF/data/material hatches, circuit connectivity or formal port notation. Source-qualified line patterns, contact side/order, arrows, texture classes and values stay with their domain adapters. The broader draft-profile and crispness studies are references for later qualification, not permission to infer those relationships from styling. No private worked reference art is shipped as a production renderer. diff --git a/scripts/sysml_present.py b/scripts/sysml_present.py index a9e4025..c22d106 100644 --- a/scripts/sysml_present.py +++ b/scripts/sysml_present.py @@ -19,11 +19,12 @@ def main(): parser.add_argument('--body', choices=('paper', 'white', 'tint'), default='paper') parser.add_argument('--target-width', type=int, default=1200) parser.add_argument('--show-ids', action='store_true', help='show view ID and node IDs/levels after native names (hidden by default)') + parser.add_argument('--labels', help='explicit native-element-bound presentation labels JSON') args = parser.parse_args() try: report = write_presentation(args.model, args.output, view_id=args.view, profile=load_profile(args.profile), body=args.body, - target_width=args.target_width, show_ids=args.show_ids) + target_width=args.target_width, show_ids=args.show_ids, labels=args.labels) except PresentationError as exc: report = exc.report except (OSError, ValueError, KeyError) as exc: diff --git a/tests/test_visual_projection.py b/tests/test_visual_projection.py index 3ae73db..9eccaed 100644 --- a/tests/test_visual_projection.py +++ b/tests/test_visual_projection.py @@ -46,27 +46,29 @@ def test_exact_identity_text_xml_and_determinism(self): svg, ledger = render_function_tree(projection) self.assertEqual((svg, ledger), render_function_tree(projection)) root = ET.fromstring(svg) - texts = [item.text for item in root.iter('{http://www.w3.org/2000/svg}text')] + texts = [''.join(item.itertext()) for item in root.iter('{http://www.w3.org/2000/svg}text')] self.assertIn('Read & "quote"', texts) self.assertNotIn('', svg) self.assertNotIn('marker-end', svg) groups = [item for item in root.iter('{http://www.w3.org/2000/svg}g')] self.assertEqual([g.attrib['data-element'] for g in groups], [n['element'] for n in nodes]) - self.assertEqual(len(list(root.iter('{http://www.w3.org/2000/svg}path'))), len(nodes)-1) + self.assertEqual(len(root.findall("{http://www.w3.org/2000/svg}path[@data-child]")), len(nodes)-1) for a, b in zip(ledger['geometry'], ledger['geometry'][1:]): - self.assertLessEqual(a['y'] + a['height'], b['y']) + self.assertTrue(a['y'] + a['height'] <= b['y'] or a['x'] + a['width'] <= b['x'] or b['x'] + b['width'] <= a['x']) def test_parsed_svg_preserves_title_body_roles_and_square_containers(self): nodes, inventory = fixture() root = ET.fromstring(render_function_tree(project(nodes, inventory), show_ids=True)[0]) namespace = {'svg': 'http://www.w3.org/2000/svg'} - title, annotation = root.findall('svg:text', namespace) + title = root.find("svg:text[@data-role='title']",namespace) + annotation = root.find("svg:text[@data-role='subtitle']",namespace) self.assertEqual(title.attrib['font-family'], 'Inter') self.assertEqual(annotation.attrib['font-family'], 'Space Grotesk') groups = root.findall('svg:g', namespace) self.assertEqual(len(groups), len(nodes)) for group in groups: - native_label, metadata = group.findall('svg:text', namespace) + native_label = group.find("svg:text[@data-role='label']", namespace) + metadata = group.find("svg:text[@data-role='metadata']", namespace) self.assertIn('level', metadata.text) self.assertEqual(metadata.attrib['font-family'], 'Space Grotesk') self.assertEqual(native_label.attrib['font-family'], 'Space Grotesk') @@ -74,73 +76,47 @@ def test_parsed_svg_preserves_title_body_roles_and_square_containers(self): self.assertNotIn('rx', rectangle.attrib) self.assertNotIn('ry', rectangle.attrib) - def test_profile_sizes_keep_line_spacing_containment_and_parent_endpoints(self): + def test_content_fit_wrapping_and_attached_documentary_routes(self): nodes, inventory = fixture() projection = project(nodes, inventory) - namespace = {'svg': 'http://www.w3.org/2000/svg'} + ns = {'svg':'http://www.w3.org/2000/svg'} for size in (20, 22, 24): - with self.subTest(body_size=size): - profile = load_profile() - profile['numeric_proposal']['body_size'] = size - svg, ledger = render_function_tree(projection, profile=profile, target_width=2200, show_ids=True) - root = ET.fromstring(svg) - boxes = {} - for group in root.findall('svg:g', namespace): - key = group.attrib['data-id'] - rect = group.find('svg:rect', namespace) - box = {k: float(rect.attrib[k]) for k in ('x', 'y', 'width', 'height')} - boxes[key] = box - label, metadata = group.findall('svg:text', namespace) - top_baseline, bottom_baseline = float(label.attrib['y']), float(metadata.attrib['y']) - self.assertEqual(float(metadata.attrib['font-size']), size) - self.assertEqual(float(label.attrib['font-size']), size) - self.assertGreaterEqual(bottom_baseline - top_baseline, 1.5 * size) - self.assertGreaterEqual(top_baseline - size, box['y'] + 20) - self.assertLessEqual(bottom_baseline + size * 0.4, box['y'] + box['height'] - 16) - self.assertLess(box['y'] + box['height'], ledger['canvas_height']) - ordered = sorted(boxes.values(), key=lambda box: box['y']) - for prior, following in zip(ordered, ordered[1:]): - self.assertGreaterEqual(following['y'] - prior['y'] - prior['height'], 32) - for path in root.findall('svg:path', namespace): - parent = boxes[path.attrib['data-parent']] - child = boxes[path.attrib['data-child']] - start_x, start_y, end_y, end_x = map(float, re.findall(r'[0-9]+(?:\.[0-9]+)?', path.attrib['d'])) - self.assertTrue(parent['x'] < start_x < parent['x'] + parent['width']) - self.assertEqual(start_y, parent['y'] + parent['height']) - self.assertEqual(end_y, child['y'] + child['height'] / 2) - self.assertEqual(end_x, child['x']) - self.assertEqual(ledger['layout']['line_height'], size * 1.5) - self.assertIn('require browser inspection', ledger['layout']['scope']) - self.assertEqual(projection['nodes'][0]['text'], 'Process item') + profile = load_profile() + profile['numeric_proposal']['body_size'] = size + svg, ledger = render_function_tree(projection, profile=profile, show_ids=True, target_width=1600) + tree = ET.fromstring(svg) + boxes = {g.attrib['data-id']:g.find('svg:rect',ns).attrib for g in tree.findall('svg:g',ns)} + self.assertEqual({(e['parent'],e['child']) for e in ledger['edges']}, {('F0','F1'),('F0','F2')}) + for group in tree.findall('svg:g',ns): + self.assertLess(float(group.find('svg:rect',ns).attrib['width']), 450) + label=group.find("svg:text[@data-role='label']",ns) + self.assertEqual(float(label.attrib['font-size']),size) + for edge in ledger['edges']: + parent,child=boxes[edge['parent']],boxes[edge['child']] + start,end=edge['points'][0],edge['points'][-1] + self.assertEqual(start[1],float(parent['y'])+float(parent['height'])) + self.assertEqual(end[0],float(child['x'])) + self.assertEqual(end[1],float(child['y'])+float(child['height'])/2) + projection['nodes'][1]['text']='A long native engineering name ' * 10 + before=deepcopy(projection) + tree=ET.fromstring(render_function_tree(projection,target_width=1600)[0]) + label=tree.findall('svg:g',ns)[1].find("svg:text[@data-role='label']",ns) + self.assertEqual(''.join(label.itertext()),projection['nodes'][1]['text']) + self.assertGreater(len(label.findall('svg:tspan',ns)),2) + self.assertEqual(projection,before) - def test_wide_view_and_metadata_ids_use_selected_font_size_bounds(self): - nodes, inventory = fixture() - nodes[0]['id'] = 'W' * 64 - nodes[1]['parent'] = nodes[2]['parent'] = nodes[0]['id'] - project(nodes, inventory) - view_id = 'W' * 64 - data = json.loads(inventory['documents'][0]['body'].split('\n', 1)[1]) - data['views'][0]['id'] = view_id - inventory['documents'][0]['body'] = 'CSE-VIEWS/1.0\n' + json.dumps(data) - projection = project_function_tree(inventory, view_id) - for title_size, body_size in ((36, 22), (48, 24)): - with self.subTest(title_size=title_size, body_size=body_size): - profile = load_profile() - profile['numeric_proposal'].update(title_size=title_size, body_size=body_size) - with self.assertRaisesRegex(ValueError, 'Target too small'): - render_function_tree(projection, profile=profile, target_width=2200, show_ids=True) - svg, ledger = render_function_tree(projection, profile=profile, target_width=7200, show_ids=True) - tree = ET.fromstring(svg) - title = tree.find('{http://www.w3.org/2000/svg}text') - self.assertEqual(title.text, view_id + ' - Function decomposition') - self.assertGreaterEqual(ledger['canvas_width'] - float(title.attrib['x']) - 48, - len(title.text) * 1.5 * title_size) - for group in tree.findall('{http://www.w3.org/2000/svg}g'): - rectangle = group.find('{http://www.w3.org/2000/svg}rect') - for text in group.findall('{http://www.w3.org/2000/svg}text'): - self.assertGreaterEqual(float(rectangle.attrib['width']) - 40, - len(text.text) * 1.5 * body_size) - self.assertIn(view_id, svg) + def test_wide_view_ids_wrap_without_truncation_and_small_targets_refuse(self): + nodes,inventory=fixture() + projection=project(nodes,inventory) + projection['id']='W'*64 + svg,ledger=render_function_tree(projection,show_ids=True,target_width=1600) + tree=ET.fromstring(svg) + title=tree.find('{http://www.w3.org/2000/svg}text') + self.assertEqual(''.join(title.itertext()),'W'*64+' - Function decomposition') + self.assertGreater(len(title),1) + for target in (320,400): + with self.assertRaisesRegex(ValueError,'Target too small'): + render_function_tree(projection,show_ids=True,target_width=target) def test_default_names_and_opt_in_metadata_preserve_semantics(self): nodes, inventory = fixture() @@ -153,15 +129,17 @@ def test_default_names_and_opt_in_metadata_preserve_semantics(self): with self.subTest(show_ids=show_ids): svg, ledger = render_function_tree(projection, show_ids=show_ids) tree = ET.fromstring(svg) - title, subtitle = tree.findall('svg:text', ns) - self.assertEqual(title.text, ('FT - ' if show_ids else '') + 'Function decomposition') + title=tree.find("svg:text[@data-role='title']",ns) + subtitle=tree.find("svg:text[@data-role='subtitle']",ns) + self.assertEqual(''.join(title.itertext()), ('FT - ' if show_ids else '') + 'Function decomposition') self.assertEqual(subtitle.text, 'Documentary function hierarchy') for group, node in zip(tree.findall('svg:g', ns), projection['nodes']): - labels = group.findall('svg:text', ns) - self.assertEqual(labels[0].text, node['text']) - self.assertEqual(len(labels), 2 if show_ids else 1) + labels = group.findall("svg:text[@data-role='label']", ns) + self.assertEqual(''.join(labels[0].itertext()), node['text']) + metadata=group.findall("svg:text[@data-role='metadata']",ns) + self.assertEqual(bool(metadata),show_ids) if show_ids: - self.assertEqual(labels[1].text, f"{node['id']} · level {node['level']}") + self.assertEqual(''.join(t.text or '' for t in metadata), f"{node['id']} · level {node['level']}") self.assertEqual(group.attrib['data-id'], node['id']) self.assertEqual(group.attrib['data-level'], str(node['level'])) self.assertEqual(group.attrib['data-element'], node['element']) @@ -190,6 +168,19 @@ def test_invalid_structure_bindings_and_requested_views_refused(self): else: with self.assertRaises(PresentationError): project(nodes, inventory) + def test_shared_parent_trunk_is_single_neutral_route_with_explicit_child_drops(self): + nodes,inventory=fixture();projection=project(nodes,inventory) + svg,ledger=render_function_tree(projection) + ns={'svg':'http://www.w3.org/2000/svg'};tree=ET.fromstring(svg) + trunks=tree.findall("svg:path[@data-shared-parent='F0']",ns) + self.assertEqual(len(trunks),1) + self.assertEqual(trunks[0].attrib['stroke'],load_profile()['palette']['ink']) + drops=tree.findall('svg:path[@data-child]',ns) + self.assertEqual({p.attrib['data-child'] for p in drops},{'F1','F2'}) + self.assertTrue(all(p.attrib['data-parent']=='F0' for p in drops)) + self.assertEqual({e['child'] for e in ledger['edges']},{'F1','F2'}) + self.assertEqual(len(ledger['shared_routes']),1) + def test_render_revalidates_direct_projection_and_bounds(self): nodes, inventory = fixture() projection = project(nodes, inventory) @@ -296,32 +287,86 @@ def test_native_cli_accepts_nondefault_profile_and_preserves_provisional_status( self.assertEqual(presentation['style']['body_status'], 'provisional') self.assertEqual(presentation['body'], 'tint') tree = ET.parse(output / 'view.svg') - labels = tree.findall('.//{http://www.w3.org/2000/svg}g/{http://www.w3.org/2000/svg}text') + labels = tree.findall(".//{http://www.w3.org/2000/svg}g/{http://www.w3.org/2000/svg}text[@data-role='label']") self.assertTrue(labels) self.assertTrue(all(label.attrib['font-size'] == '23' for label in labels)) self.assertEqual(profile_path.read_text(), json.dumps(profile)) - def test_native_wide_title_refuses_small_target_without_output(self): + def test_native_wide_title_wraps_and_small_scale_refuses_without_output(self): (ROOT / '.tmp').mkdir(exist_ok=True) - with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: - temp = Path(temp) - view_id = 'W' * 64 - source = temp / 'wide.sysml' - source.write_text(MODEL.read_text().replace('"id": "FT"', '"id": "' + view_id + '"')) - profile = load_profile() - profile['numeric_proposal'].update(title_size=48, body_size=24) - small = temp / 'too-small' - with self.assertRaisesRegex(ValueError, 'Target too small'): - write_presentation(source, small, view_id=view_id, profile=profile, target_width=2200, show_ids=True) - self.assertFalse(small.exists()) - wide = temp / 'wide' - result = write_presentation(source, wide, view_id=view_id, profile=profile, target_width=7200, show_ids=True) - self.assertEqual(result['status'], 'pass') - title = ET.parse(wide / 'view.svg').find('{http://www.w3.org/2000/svg}text') - self.assertEqual(title.text, view_id + ' - Function decomposition') - presentation = json.loads((wide / 'presentation.json').read_text()) - self.assertGreaterEqual(presentation['canvas_width'] - 96, len(title.text) * 1.5 * 48) - self.assertEqual((wide / 'sources/model-1.sysml').read_bytes(), source.read_bytes()) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as folder: + temp=Path(folder);view_id='W'*64;source=temp/'wide.sysml' + source.write_text(MODEL.read_text().replace('"id": "FT"','"id": "'+view_id+'"')) + with self.assertRaisesRegex(ValueError,'Target too small'): + write_presentation(source,temp/'small',view_id=view_id,target_width=320,show_ids=True) + self.assertFalse((temp/'small').exists()) + result=write_presentation(source,temp/'wide',view_id=view_id,target_width=1600,show_ids=True) + self.assertEqual(result['status'],'pass') + title=ET.parse(temp/'wide/view.svg').find('{http://www.w3.org/2000/svg}text') + self.assertEqual(''.join(title.itertext()),view_id+' - Function decomposition') + self.assertGreater(len(title),1) + self.assertEqual((temp/'wide/sources/model-1.sysml').read_bytes(),source.read_bytes()) + + def test_explicit_display_label_cli_sources_and_invalid_binding_refusal(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as folder: + temp=Path(folder);model=ROOT/'examples/sysml/views/field-service-demo.sysml' + labels=json.loads((ROOT/'examples/sysml/views/field-service-demo.labels.json').read_text()) + source=temp/'owned.sysml';source.write_bytes(model.read_bytes()) + label_path=temp/'labels.json';label_path.write_text(json.dumps(labels)) + cmd=[sys.executable,str(ROOT/'scripts/sysml_present.py'),str(source),'--view','FT_DEMO', + '--labels',str(label_path),'--target-width','1600','--output',str(temp/'first')] + first=subprocess.run(cmd,cwd=ROOT,capture_output=True,text=True) + self.assertEqual(first.returncode,0,first.stdout+first.stderr) + ledger=(temp/'first/semantic-ledger.json').read_bytes() + native=json.loads(ledger) + self.assertIn('MaintainStation',[n['text'] for n in native['nodes']]) + self.assertNotIn('Maintain the inspection station',[n['text'] for n in native['nodes']]) + labels['labels'][0]['text']='Maintain the owned inspection station' + label_path.write_text(json.dumps(labels));cmd[-1]=str(temp/'wording-edit') + edited=subprocess.run(cmd,cwd=ROOT,capture_output=True,text=True) + self.assertEqual(edited.returncode,0,edited.stdout) + self.assertEqual((temp/'wording-edit/semantic-ledger.json').read_bytes(),ledger) + svg=ET.parse(temp/'wording-edit/view.svg') + self.assertIn('Maintain the owned inspection station',[''.join(t.itertext()) for t in svg.findall('.//{http://www.w3.org/2000/svg}text')]) + source.write_text(source.read_text().replace('MaintainStation','MaintainOwnedStation')) + cmd[-1]=str(temp/'stale-binding') + failed=subprocess.run(cmd,cwd=ROOT,capture_output=True,text=True) + self.assertEqual(failed.returncode,1) + self.assertIn('outside the selected native view',failed.stdout) + self.assertFalse((temp/'stale-binding').exists()) + labels['labels'][0]['element']='FieldServiceDemo::MaintainOwnedStation' + label_path.write_text(json.dumps(labels));cmd[-1]=str(temp/'source-edit') + repaired=subprocess.run(cmd,cwd=ROOT,capture_output=True,text=True) + self.assertEqual(repaired.returncode,0,repaired.stdout) + self.assertNotEqual((temp/'source-edit/semantic-ledger.json').read_bytes(),ledger) + self.assertEqual((temp/'source-edit/sources/model-1.sysml').read_bytes(),source.read_bytes()) + self.assertEqual(json.loads((temp/'source-edit/display-labels.json').read_text()),labels) + + def test_label_file_changes_after_capture_cannot_change_bundle_companion(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as folder: + temp=Path(folder);model=ROOT/'examples/sysml/views/field-service-demo.sysml' + labels=json.loads((ROOT/'examples/sysml/views/field-service-demo.labels.json').read_text()) + path=temp/'labels.json';path.write_text(json.dumps(labels)) + real_render=render_function_tree + def changed_after_render(*args,**kwargs): + result=real_render(*args,**kwargs) + changed=deepcopy(labels);changed['title']='Changed after render' + changed['labels'][0]['element']='Unknown::Other' + path.write_text(json.dumps(changed)) + return result + with patch('verifier.visual_projection.render_function_tree',side_effect=changed_after_render): + report=write_presentation(model,temp/'output',view_id='FT_DEMO',labels=path,target_width=1600) + self.assertEqual(report['status'],'pass') + saved=json.loads((temp/'output/display-labels.json').read_text()) + self.assertEqual(saved,labels) + shown=json.loads((temp/'output/presentation.json').read_text()) + self.assertEqual(shown['display']['title'],labels['title']) + self.assertEqual(shown['display']['bindings'],labels['labels']) + self.assertNotEqual(json.loads(path.read_text()),saved) + for name,digest in report['manifest']['files'].items(): + self.assertEqual(sha256((temp/'output'/name).read_bytes()).hexdigest(),digest) def test_cli_invalid_view_and_missing_parser_diagnostics(self): (ROOT / '.tmp').mkdir(exist_ok=True) diff --git a/tests/test_visual_style.py b/tests/test_visual_style.py index 1a92c9e..697e3cb 100644 --- a/tests/test_visual_style.py +++ b/tests/test_visual_style.py @@ -6,7 +6,7 @@ from copy import deepcopy ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from verifier.visual_style import load_profile, contrast, presentation_style, require_supported_text, font_css +from verifier.visual_style import load_profile, contrast, presentation_style, require_supported_text, font_css, text_width, wrap_text class VisualStyleTests(unittest.TestCase): def test_palette_and_provisional_scope(self): @@ -59,6 +59,15 @@ def test_nondefault_profile_dict_and_file_share_strict_validation(self): p["body_variants"]["status"] = "accepted" with self.assertRaises(ValueError): presentation_style(p) + def test_measured_wrapping_preserves_text_and_tracks_actual_face(self): + self.assertGreater(text_width('WWW',22),text_width('iii',22)*2) + self.assertAlmostEqual(text_width('ABC',22),text_width('A',22)+text_width('B',22)+text_width('C',22)) + for text in ('A long engineering name with all spaces retained', 'W'*64, ' exact spaces '): + lines=wrap_text(text,150,22) + self.assertEqual(''.join(lines),text) + self.assertTrue(all(text_width(line,22)<=150 for line in lines)) + with self.assertRaises(ValueError):wrap_text('W',1,22) + def test_font_bytes_and_explicit_coverage(self): css = font_css() self.assertEqual(css.count("@font-face"), 4) diff --git a/verifier/visual_projection.py b/verifier/visual_projection.py index 4d577f3..3f423b6 100644 --- a/verifier/visual_projection.py +++ b/verifier/visual_projection.py @@ -10,7 +10,8 @@ from .engineering_views import check_views from .sysml import check_sysml -from .visual_style import ASSETS, load_profile, presentation_style, require_supported_text +from .visual_style import ASSETS, load_profile, presentation_style, require_supported_text, text_width, wrap_text, contrast +from .visual_labels import apply_labels, load_labels VERSION = "cse-visual-presentation-1.0" CLAIM = "Simplified model-derived presentation: documentary decomposition; no behavioral or engineering acceptance claim." @@ -55,93 +56,181 @@ def project_function_tree(inventory, view_id): "nodes": nodes, "view_checks": checks} -def render_function_tree(projection, *, profile=None, body="paper", target_width=1200, show_ids=False): - """Render an arrow-free indented tree; preserve labels as literal XML text.""" +def render_function_tree(projection, *, profile=None, body="paper", target_width=1200, show_ids=False, labels=None): + """Content-fit documentary tree; explicit display wording never changes native facts.""" if type(show_ids) is not bool: raise ValueError("show_ids must be a boolean") if type(target_width) not in (int, float) or not math.isfinite(target_width) or not 320 <= target_width <= 8192: raise ValueError("Target width must be between 320 and 8192 pixels") - # Recheck the public structural contract even when called without project(). if projection.get("kind") != "function_tree" or projection.get("version") != VERSION: raise ValueError("Expected bounded function-tree presentation version") raw_nodes = [{key: n[key] for key in ("id", "element", "level", "parent")} for n in projection["nodes"]] inventory = {"documents": [{"name": "cseViews", "owner_kind": "Package", "body": "CSE-VIEWS/1.0\n" + json.dumps({"views": [{"id": projection["id"], "kind": "function_tree", "nodes": raw_nodes}]})}], "parts": [], "actions": [{"qualified_name": n["element"], "name": n["text"]} for n in projection["nodes"]]} - checked = project_function_tree(inventory, projection["id"]) - nodes = checked["nodes"] - require_supported_text(projection["id"]) - for node in nodes: - require_supported_text(node["text"]) - by_id = {node["id"]: node for node in nodes} - children = {node["id"]: [] for node in nodes} - for node in nodes: - if node["parent"] is not None: - children[node["parent"]].append(node["id"]) - root = next(node["id"] for node in nodes if node["parent"] is None) - order, pending = [], [root] - while pending: - key = pending.pop() - order.append(key) - pending.extend(reversed(children[key])) - # Validate the selected profile before using any numerical layout values. - # Qualified printable glyphs get a conservative 1.5-em advance bound; - # actual loaded glyph bounds remain a separate browser inspection gate. - style = presentation_style(profile, body=body, target_width=1200, canvas_width=1200) - title_text = (projection["id"] + " - " if show_ids else "") + "Function decomposition" - body_advance = 1.5 * style["body_size"] - title_advance = 1.5 * style["title_size"] - width = math.ceil(max(1200, 96 + len(title_text) * title_advance, - *(48 + n["level"] * 48 + 40 + 48 + body_advance * max( - len(n["text"]), len(f"{n['id']} · level {n['level']}") if show_ids else 0) for n in nodes))) - # Reapply the readable-label gate to the complete calculated canvas. + nodes = project_function_tree(inventory, projection["id"])["nodes"] + wording = apply_labels(projection, labels) + style = presentation_style(profile, body=body) + palette = load_profile(profile)['palette'] + by_id = {n['id']: n for n in nodes} + children = {n['id']: [] for n in nodes} + for n in nodes: + if n['parent'] is not None: + children[n['parent']].append(n['id']) + root = next(n['id'] for n in nodes if n['parent'] is None) + families, roles = ['blue', 'tangerine', 'green', 'cyan', 'berry', 'amber'], {root: 'violet'} + def assign(key, family): + roles[key] = family + for child in children[key]: + assign(child, family) + for index, key in enumerate(children[root]): + assign(key, families[index % len(families)]) + boxes = {} + size, padding, key_width = style['body_size'], 20, 6 + line_height = math.ceil(size * 1.4) + for n in nodes: + key = n['id'] + weight = 700 if children[key] else 400 + lines = wrap_text(wording['labels'][key], 300, size, weight=weight) + footer = ([f"{len(children[key])} subfunction" + ('s' if len(children[key]) != 1 else '')] if children[key] else []) + if show_ids: + footer.extend(wrap_text(f"{key} · level {n['level']}", 300, 16)) + content_width = max([text_width(line, size, weight=weight) for line in lines] + + [text_width(line, 16 if ' · level ' in line or (show_ids and not children[key]) else 18) for line in footer] + [100]) + label_height = 2 * padding + len(lines) * line_height + footer_height = 20 + 25 * len(footer) if footer else 0 + boxes[key] = {'width': math.ceil(content_width + 2 * padding + key_width + 8), + 'height': label_height + footer_height, 'label_height': label_height, + 'lines': lines, 'footer': footer, 'weight': weight} + subtrees = {} + def measure(key): + box, kids = boxes[key], children[key] + mode = 'leaf' if not kids else ('stack' if all(not children[c] for c in kids) else 'branches') + for c in kids: + measure(c) + if mode == 'leaf': + width, height = box['width'], box['height'] + elif mode == 'stack': + width = max(box['width'], max(boxes[c]['width'] for c in kids) + 32) + height = box['height'] + 56 + sum(boxes[c]['height'] for c in kids) + 18 * (len(kids)-1) + else: + width = max(box['width'], sum(subtrees[c]['width'] for c in kids) + 48 * (len(kids)-1)) + height = box['height'] + 64 + max(subtrees[c]['height'] for c in kids) + subtrees[key] = {'width': width, 'height': height, 'mode': mode} + measure(root) + width = math.ceil(max(target_width, subtrees[root]['width'] + 96, + text_width('Branch colors identify documentary groups; lines show decomposition.', 18) + 96)) style = presentation_style(profile, body=body, target_width=target_width, canvas_width=width) - # Conservative line boxes depend on the qualified profile size. Browser - # glyph-bounds inspection remains a separate check, not an arithmetic claim. - padding = 20 - line_height = math.ceil(1.5 * style["body_size"]) - first_baseline = padding + math.ceil(1.2 * style["body_size"]) - second_baseline = first_baseline + line_height - node_height = 2 * line_height + 2 * padding - row_stride = node_height + 32 - height = 112 + len(nodes) * row_stride + 16 - positions = {key: (48 + by_id[key]["level"] * 48, 112 + i * row_stride) for i, key in enumerate(order)} - svg = [f'', - f'{escape(title_text)}', - f'{escape(CLAIM)}', - '', + smallest = 16 if show_ids else 18 + if smallest * target_width / width < 16: + raise ValueError('Target too small for 16px labels and counts; use native width or scrolling') + title_text = (projection['id'] + ' - ' if show_ids else '') + wording['title'] + title_lines = wrap_text(title_text, width - 96, style['title_size'], 'Inter', 700) + title_leading = math.ceil(style['title_size'] * 1.2) + graph_top = 48 + len(title_lines) * title_leading + 76 + height = math.ceil(graph_top + subtrees[root]['height'] + 96) + if width > 20000 or height > 20000: + raise ValueError('Presentation exceeds bounded 20000-unit canvas; select a smaller view') + positions, edge_points = {}, {} + def place(key, left, top): + b, tree, kids = boxes[key], subtrees[key], children[key] + x = left + (tree['width'] - b['width']) / 2 + positions[key] = (x, top) + if tree['mode'] == 'stack': + stack_width = max(boxes[c]['width'] for c in kids) + 32 + leaf_left = left + (tree['width'] - stack_width) / 2 + 32 + spine = leaf_left - 24 + y = top + b['height'] + 56 + for c in kids: + positions[c] = (leaf_left, y) + edge_points[c] = {'from_side': 'bottom', 'to_side': 'left', 'points': + [(x+b['width']/2, top+b['height']), (x+b['width']/2, top+b['height']+24), + (spine, top+b['height']+24), (spine, y+boxes[c]['height']/2), + (leaf_left, y+boxes[c]['height']/2)]} + y += boxes[c]['height'] + 18 + elif tree['mode'] == 'branches': + kids_width = sum(subtrees[c]['width'] for c in kids) + 48 * (len(kids)-1) + cursor = left + (tree['width'] - kids_width)/2 + for c in kids: + cy = top + b['height'] + 64 + place(c, cursor, cy) + cx, _ = positions[c] + edge_points[c] = {'from_side': 'bottom', 'to_side': 'top', 'points': + [(x+b['width']/2, top+b['height']), (x+b['width']/2, top+b['height']+32), + (cx+boxes[c]['width']/2, top+b['height']+32), (cx+boxes[c]['width']/2, cy)]} + cursor += subtrees[c]['width'] + 48 + place(root, (width-subtrees[root]['width'])/2, graph_top) + def text(lines, x, baseline, fontsize, color, *, family='Space Grotesk', weight=400, role='label', leading=None): + leading = leading or math.ceil(fontsize*1.4) + attrs = f'xml:space="preserve" data-role="{role}" x="{x:g}" y="{baseline:g}" fill="{color}" font-family="{family}" font-size="{fontsize}" font-weight="{weight}"' + if len(lines) == 1: + return f'{escape(lines[0])}' + return f'' + ''.join(f'{escape(line)}' for i,line in enumerate(lines)) + '' + svg = [f'', + f'{escape(title_text)}', f'{escape(CLAIM)}', + '', f'', - f'{escape(title_text)}', - f'Documentary function hierarchy'] - for key in order: - node = by_id[key] - if node['parent'] is not None: - px, py = positions[node['parent']] - x, y = positions[key] - svg.append(f'') + text(title_lines,48,48,style['title_size'],style['ink'],family='Inter',weight=700,role='title',leading=title_leading), + text(['Documentary function hierarchy'],48,48+len(title_lines)*title_leading+6,18,style['ink'],role='subtitle')] + shared_routes = [] + for parent, kids in children.items(): + if not kids: + continue + if subtrees[parent]['mode'] == 'branches': + first = edge_points[kids[0]]['points'] + drops = [edge_points[c]['points'][2] for c in kids] + segments = [[first[0],first[1]],[(min(p[0] for p in drops),first[1][1]), + (max(p[0] for p in drops),first[1][1])]] + color = style['ink'] + else: + first,last = edge_points[kids[0]]['points'],edge_points[kids[-1]]['points'] + segments = [[first[0],first[1],first[2],last[-2]]] + color = style['ink'] if parent == root else palette['families'][roles[parent]]['shade'] + commands = ['M ' + ' L '.join(f'{x:g} {y:g}' for x,y in points) for points in segments] + svg.append(f'') + shared_routes.append({'parent':parent,'segments':segments,'color':color}) + for key, edge in edge_points.items(): + # Shared trunks are painted once; this path is the uniquely owned drop. + points = edge['points'][-2:] + path = 'M ' + ' L '.join(f'{x:g} {y:g}' for x,y in points) + svg.append(f'') geometry = [] - for key in order: - node = by_id[key] - x, y = positions[key] - box_width = width - x - 48 - attrs = ' '.join(f'data-{k}="{escape(str(node[k]), quote=True)}"' for k in ("id", "element", "level", "parent")) - svg.extend([f'', f'', - f'{escape(node["text"])}']) - if show_ids: - svg.append(f'{escape(key)} · level {node["level"]}') + for node in nodes: + key = node['id']; x,y = positions[key]; b = boxes[key] + family = palette['families'][roles[key]] + fill = family['tint'] if children[key] else style['fill'] + border = style['accent'] if key == root else family['shade'] + if contrast(style['ink'], fill) < 4.5 or min(contrast(border, fill), contrast(border, style['paper'])) < 3: + raise ValueError('Insufficient contrast for local tree presentation roles') + attrs = ' '.join(f'data-{k}="{escape(str(node[k]),quote=True)}"' for k in ('id','element','level','parent')) + f' data-native-name="{escape(node["text"],quote=True)}"' + svg.extend([f'', f'', + f'', + text(b['lines'], x+padding+key_width, y+padding+size, size, style['ink'], weight=b['weight'])]) + if b['footer']: + fy = y + b['label_height'] + svg.append(f'') + for index,line in enumerate(b['footer']): + is_count = bool(children[key]) and index == 0 + svg.append(text([line],x+padding+key_width,fy+25+index*25,18 if is_count else 16,style['ink'],role='count' if is_count else 'metadata')) svg.append('') - geometry.append({"id": key, "x": x, "y": y, "width": box_width, "height": node_height}) - svg.append('') - ledger = {"version": VERSION, "body": body, "target_width": target_width, "canvas_width": width, - "canvas_height": height, "claim_scope": CLAIM, "show_ids": show_ids, - "layout": {"line_height": line_height, "node_height": node_height, - "row_stride": row_stride, "padding": padding, - "first_baseline": first_baseline, "second_baseline": second_baseline, - "scope": "Conservative font-size-based spacing; actual loaded glyph bounds require browser inspection."}, "geometry": geometry, "style": style} - return '\n'.join(svg) + '\n', ledger - - -def write_presentation(model_paths, output, *, view_id, profile=None, body="paper", target_width=1200, show_ids=False): + geometry.append({'id':key,'x':x,'y':y,'width':b['width'],'height':b['height'], + 'label_lines':b['lines'],'label_font_weight':b['weight'],'label_baseline':y+padding+size, + 'label_region_height':b['label_height'],'footer_lines':b['footer'],'palette_family':roles[key]}) + svg.extend([text(['Branch colors identify documentary groups; lines show decomposition.'],48,height-32,18,style['ink'],role='legend'),'']) + ledger = {'version':VERSION,'body':body,'target_width':target_width,'canvas_width':width,'canvas_height':height, + 'claim_scope':CLAIM,'show_ids':show_ids,'display':wording,'geometry':geometry, + 'edges':[{'parent':by_id[k]['parent'],'child':k,**v} for k,v in edge_points.items()], + 'shared_routes':shared_routes, + 'layout':{'kind':'balanced-branches-with-stacked-leaves','padding':padding,'line_height':line_height, + 'group_gap':48,'leaf_gap':18,'max_label_width':300, + 'scope':'Measured advance-based wrapping; actual loaded glyph bounds require browser inspection.'}, + 'presentation_roles':{'root':'violet tint with tangerine shade key','branches':'selected palette families repeated within each first-level branch', + 'meaning':'Local navigation only; no universal engineering or status semantics','body_status':'provisional'}, + 'font_metrics_sha256':_digest((ASSETS/'fonts/metrics.json').read_bytes()),'style':style} + return '\n'.join(svg)+'\n',ledger + + +def write_presentation(model_paths, output, *, view_id, profile=None, body="paper", target_width=1200, show_ids=False, labels=None): """Acquire a native inventory and write a new, independently editable bundle.""" output = Path(output) if output.exists() or output.is_symlink(): @@ -152,7 +241,8 @@ def write_presentation(model_paths, output, *, view_id, profile=None, body="pape return report projection = project_function_tree(report["inventory"], view_id) validated_profile = load_profile(profile) if profile is None or isinstance(profile, (str, Path)) else deepcopy(profile) - svg, presentation = render_function_tree(projection, profile=validated_profile, body=body, target_width=target_width, show_ids=show_ids) + validated_labels = load_labels(labels) + svg, presentation = render_function_tree(projection, profile=validated_profile, body=body, target_width=target_width, show_ids=show_ids, labels=validated_labels) # Capture source bytes and verify parser pins before creating any output. sources = {} for path in paths: @@ -165,6 +255,8 @@ def write_presentation(model_paths, output, *, view_id, profile=None, body="pape sources[resolved.as_posix()] = raw files = {"view.svg": svg.encode(), "semantic-ledger.json": _json(projection).encode(), "presentation.json": _json(presentation).encode()} + if validated_labels is not None: + files["display-labels.json"] = _json(validated_labels).encode() # Plain offline HTML: exact SVG, embedded fonts, scroll and native browser zoom. html = '\nFunction decomposition

' + escape(CLAIM) + '

Scroll to inspect the diagram; use browser zoom to enlarge.

' + svg + '
\n' files["index.html"] = html.encode() @@ -183,8 +275,9 @@ def write_presentation(model_paths, output, *, view_id, profile=None, body="pape "checker_version": report["checker_version"], "sources": source_manifest, "style_sha256": _digest(_json(validated_profile).encode()), "font_manifest_sha256": _digest(font_manifest_raw), + "font_metrics_sha256": _digest((ASSETS / "fonts/metrics.json").read_bytes()), "implementation_sha256": {str(Path('verifier') / name): _digest((Path(__file__).parent / name).read_bytes()) - for name in ('visual_projection.py', 'visual_style.py', 'engineering_views.py', 'sysml.py')}, + for name in ('visual_projection.py', 'visual_style.py', 'visual_labels.py', 'engineering_views.py', 'sysml.py')}, "files": {name: _digest(raw) for name, raw in sorted(files.items())}, "editable_source": "sources/*.sysml are exact source companions; edit and regenerate with sysml_present.py. No editor roundtrip claim."} files['manifest.json'] = _json(manifest).encode() diff --git a/verifier/visual_style.py b/verifier/visual_style.py index 11d5423..337ed3d 100644 --- a/verifier/visual_style.py +++ b/verifier/visual_style.py @@ -1,6 +1,7 @@ """Opt-in presentation values; never model meaning or engineering acceptance.""" import base64 from copy import deepcopy +from functools import lru_cache from hashlib import sha256 import json import math @@ -127,3 +128,50 @@ def presentation_style(profile=None, *, body="paper", target_width=1200, canvas_ "font_css": font_css(), "body_status": "provisional", "contrast": {"body_text": contrast(palette["ink"], fill), "accent_text": contrast(palette["families"]["tangerine"]["shade"], palette["families"]["violet"]["tint"])}} + + +def text_width(text, size, family='Space Grotesk', weight=400): + """Advance sum from bundled-face measurements; no fitting or text distortion.""" + require_supported_text(text) + metrics = _font_metrics() + face = metrics['faces'][f'{family}:{weight}'] + return sum(face['advances'][char] for char in text) * size / face['units'] + + +def wrap_text(text, max_width, size, family='Space Grotesk', weight=400): + """Wrap at spaces, then glyph boundaries; concatenated lines retain every character.""" + require_supported_text(text) + lines, start = [], 0 + while start < len(text): + end, last_space, width = start, None, 0 + while end < len(text): + advance = text_width(text[end], size, family, weight) + if width + advance > max_width and end > start: + break + if advance > max_width: + raise ValueError('Text region cannot contain one qualified glyph') + width += advance + end += 1 + if text[end-1] == ' ': + last_space = end + if end < len(text) and last_space is not None: + end = last_space + lines.append(text[start:end]) + start = end + return lines + + +@lru_cache(maxsize=1) +def _font_metrics(): + metrics = json.loads((ASSETS / 'fonts/metrics.json').read_text()) + manifest = json.loads((ASSETS / 'fonts/manifest.json').read_text()) + expected = {item['family'] + ':' + str(item['weight']): item['sha256'] for item in manifest['faces']} + chars = set(chr(i) for i in range(32, 127)) | {'\u00b7'} + if metrics.get('version') != 1 or set(metrics.get('faces', {})) != set(expected): + raise ValueError('Unsupported qualified font metrics') + for key, face in metrics['faces'].items(): + if face.get('font_sha256') != expected[key] or face.get('units') != 1000 or set(face.get('advances', {})) != chars: + raise ValueError('Font metrics do not match bundled face or qualified coverage') + if any(type(v) not in (int, float) or not math.isfinite(v) or v <= 0 for v in face['advances'].values()): + raise ValueError('Invalid measured glyph advance') + return metrics From acadc2bf6dbe5e5441b6b4d83a7ea142a2aaa3c2 Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:12:45 +0000 Subject: [PATCH 09/10] Separate function-tree header and connector palette roles --- assets/diagram-style/design-tokens.json | 34 ++++++++++++++- assets/diagram-style/profile.json | 27 ++++++++++++ docs/visual-presentation.md | 14 +++++- tests/test_visual_projection.py | 34 +++++++++++++++ tests/test_visual_style.py | 32 +++++++++++++- verifier/visual_projection.py | 22 +++++----- verifier/visual_style.py | 58 ++++++++++++++++++++++++- 7 files changed, 206 insertions(+), 15 deletions(-) diff --git a/assets/diagram-style/design-tokens.json b/assets/diagram-style/design-tokens.json index 55b5c5c..3f774f2 100644 --- a/assets/diagram-style/design-tokens.json +++ b/assets/diagram-style/design-tokens.json @@ -500,5 +500,37 @@ "Accessibility and representative reader-comprehension evaluation." ] }, - "delivery_boundary": "This document contains reusable presentation values and proposals only. It embeds no source figures, models, artwork, font binaries or evidence records and grants no rights to omitted material." + "delivery_boundary": "This document contains reusable presentation values and proposals only. It embeds no source figures, models, artwork, font binaries or evidence records and grants no rights to omitted material.", + "function_tree_implementation": { + "status": "SCOPED_LOCAL_DEFAULTS_NOT_UNIVERSAL_SEMANTICS", + "profile_field": "function_tree_roles", + "roles": { + "status": "slice_specific_proposal", + "root_pair": "orange_on_violet", + "branch_pairs": [ + "orange_on_violet", + "brick_on_cream", + "green_on_cool" + ], + "shared_fanout": "ink", + "pairs": { + "orange_on_violet": { + "fill": "violet.tint", + "frame": "tangerine.shade", + "connector": "tangerine.shade" + }, + "brick_on_cream": { + "fill": "amber.tint", + "frame": "red.shade", + "connector": "red.shade" + }, + "green_on_cool": { + "fill": "cyan.tint", + "frame": "green.shade", + "connector": "green.shade" + } + } + }, + "scope": "Independent header fill, frame/accent and connector references. Matching accent/drop defaults preserve branch traceability. Leaf body choices remain provisional." + } } diff --git a/assets/diagram-style/profile.json b/assets/diagram-style/profile.json index abd17bc..4f0d1ad 100644 --- a/assets/diagram-style/profile.json +++ b/assets/diagram-style/profile.json @@ -71,5 +71,32 @@ "tint": "#e8d8de" } } + }, + "function_tree_roles": { + "status": "slice_specific_proposal", + "root_pair": "orange_on_violet", + "branch_pairs": [ + "orange_on_violet", + "brick_on_cream", + "green_on_cool" + ], + "shared_fanout": "ink", + "pairs": { + "orange_on_violet": { + "fill": "violet.tint", + "frame": "tangerine.shade", + "connector": "tangerine.shade" + }, + "brick_on_cream": { + "fill": "amber.tint", + "frame": "red.shade", + "connector": "red.shade" + }, + "green_on_cool": { + "fill": "cyan.tint", + "frame": "green.shade", + "connector": "green.shade" + } + } } } diff --git a/docs/visual-presentation.md b/docs/visual-presentation.md index 0b031f4..ad9b0fc 100644 --- a/docs/visual-presentation.md +++ b/docs/visual-presentation.md @@ -29,13 +29,23 @@ Nodes wrap within a measured text region and fit their own content plus padding. | --- | --- | --- | | Square content-fit framing | Glyph-advance wrapping, bounded padding and height, no canvas-wide bars | Other notation-specific node shapes | | Useful partitions and side keys | Derived child-count cell; optional ID/level cell; a single outer frame | Domain-owned compartments | -| Selected palette shade/tint roles | Violet/tangerine root; blue, tangerine and green demo branches; readable ink labels | All eight families remain tokens; no universal status meanings | +| Selected palette shade/tint roles | Explicit orange/violet, brick/cream and green/cool header pairs; readable ink labels | All eight families remain tokens; no universal status meanings | | Inter and Space Grotesk | Embedded Inter title and Space Grotesk labels/counts, with pinned measured advances | Wider glyph qualification | | Target-scale vector geometry | Editable text/SVG; declared display-pixel strokes; fresh vector raster export | Calibrated print and editor roundtrip | | Line and hatch samples | Documentary decomposition routes only; shared trunks have explicit parents | Dashed relation semantics and typed materials/equipment textures | | Drafted exterior illustrations | No invented physical geometry in this function view | Source-admitted physical glyph adapters | -White, paper and faint-tint leaf bodies remain provisional choices; the demonstration requests white explicitly. Branch color is a local navigation role, repeated within each first-level branch. It is neither engineering status nor a selected universal palette mapping. Texture is deliberately absent: this function model declares no material or equipment classes that could give a hatch meaning. +White, paper and faint-tint leaf bodies remain provisional choices; the demonstration requests white explicitly. The profile resolves header fill, frame/accent and connector roles independently. Local defaults pair violet tint with tangerine shade, amber tint with brick shade, and cyan tint with green shade. Branch drops and local spines default to the frame/accent shade for traceability, while the shared fan-out stays neutral. These are retained contrasting-pair candidates and scoped implementation defaults, not newly invented universally approved combinations. Branch color is a local navigation role, repeated within each first-level branch. It is neither engineering status nor a selected universal palette mapping. Texture is deliberately absent: this function model declares no material or equipment classes that could give a hatch meaning. + +`function_tree_roles` is an optional version-1 profile section containing a named pair catalog, `root_pair`, ordered `branch_pairs` and the neutral `shared_fanout` role. Each pair separately references `fill`, `frame` and `connector` as palette family/tone names. Equal frame/connector defaults preserve grouping without coupling their resolution. Header fill and frame must use different families; unknown references, unsupported tones, monochromatic header pairs and insufficient contrast are refused. Legacy version-1 custom profiles inherit the bundled explicit defaults. The resolved roles and per-node pair IDs accompany the output; they do not alter the semantic ledger or geometry. + +| Local pair | Header fill | Frame/accent | Connector default | +| --- | --- | --- | --- | +| Orange on violet | Violet tint `#e0dae6` | Tangerine shade `#894b25` | Tangerine shade `#894b25` | +| Brick on cream | Amber tint `#ece1ce` | Brick shade `#7f362f` | Brick shade `#7f362f` | +| Green on cool | Cyan tint `#d4e3e7` | Green shade `#315e43` | Green shade `#315e43` | + +All text fields require at least4.5:1 contrast. Header frames and connectors require at least3:1 against their actual header or paper fields, and frames must also pass on every provisional leaf body. These arithmetic gates supplement actual pixel review of the contrasting pairings; they do not select engineering meanings or guarantee visual acceptance. Text layout uses `fonts/metrics.json`, measured from the pinned WOFF2 faces with Canvas `measureText` at 1000px and tied to each face SHA-256. Advances scale to the selected font size; normal glyph shapes and spaces are retained. This particular layout disables kerning and ligatures consistently in measurement and SVG, and requests geometric-precision text rendering to retain fractional advances at small sizes. It applies no letter spacing, fitting transforms or character substitution. Soft line breaks concatenate to the exact authored string, including spaces. Actual browser glyph bounds, face identity and target-scale clearance still need checking for each qualified candidate; advance sums alone are not visual acceptance. diff --git a/tests/test_visual_projection.py b/tests/test_visual_projection.py index 9eccaed..c7de85b 100644 --- a/tests/test_visual_projection.py +++ b/tests/test_visual_projection.py @@ -168,6 +168,29 @@ def test_invalid_structure_bindings_and_requested_views_refused(self): else: with self.assertRaises(PresentationError): project(nodes, inventory) + def test_profile_pairs_change_only_colors_without_model_or_geometry_changes(self): + nodes,inventory=fixture();projection=project(nodes,inventory);saved=deepcopy(projection) + first,ledger=render_function_tree(projection,body='white') + p=load_profile();pair=p['function_tree_roles']['pairs']['orange_on_violet'] + pair['fill']='cyan.tint';pair['connector']='blue.shade' + second,changed=render_function_tree(projection,body='white',profile=p) + self.assertNotEqual(first,second) + def non_color(svg): + tree=ET.fromstring(svg) + for item in tree.iter(): + for key in ('fill','stroke'):item.attrib.pop(key,None) + return ET.tostring(tree) + self.assertEqual(non_color(first),non_color(second)) + self.assertEqual(ledger['geometry'],changed['geometry']) + self.assertEqual(ledger['edges'],changed['edges']) + self.assertEqual(projection,saved) + root=ET.fromstring(second);ns={'svg':'http://www.w3.org/2000/svg'} + frame=root.find("svg:g[@data-id='F0']/svg:rect",ns) + self.assertEqual(frame.attrib['fill'],'#d4e3e7') + self.assertEqual(frame.attrib['stroke'],'#894b25') + drop=root.find("svg:path[@data-child='F1']",ns) + self.assertEqual(drop.attrib['stroke'],'#27456f') + def test_shared_parent_trunk_is_single_neutral_route_with_explicit_child_drops(self): nodes,inventory=fixture();projection=project(nodes,inventory) svg,ledger=render_function_tree(projection) @@ -368,6 +391,17 @@ def changed_after_render(*args,**kwargs): for name,digest in report['manifest']['files'].items(): self.assertEqual(sha256((temp/'output'/name).read_bytes()).hexdigest(),digest) + def test_programmatic_legacy_profile_manifest_pins_resolved_role_defaults(self): + (ROOT / '.tmp').mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as folder: + expected=load_profile();legacy=deepcopy(expected);legacy.pop('function_tree_roles') + saved=deepcopy(legacy) + report=write_presentation(MODEL,Path(folder)/'resolved',view_id='FT',profile=legacy) + self.assertEqual(report['status'],'pass') + canonical=json.dumps(expected,sort_keys=True,indent=2,ensure_ascii=False,allow_nan=False)+'\n' + self.assertEqual(report['manifest']['style_sha256'],sha256(canonical.encode()).hexdigest()) + self.assertEqual(legacy,saved) + def test_cli_invalid_view_and_missing_parser_diagnostics(self): (ROOT / '.tmp').mkdir(exist_ok=True) with tempfile.TemporaryDirectory(dir=ROOT / '.tmp') as temp: diff --git a/tests/test_visual_style.py b/tests/test_visual_style.py index 697e3cb..5f5adb3 100644 --- a/tests/test_visual_style.py +++ b/tests/test_visual_style.py @@ -6,7 +6,7 @@ from copy import deepcopy ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) -from verifier.visual_style import load_profile, contrast, presentation_style, require_supported_text, font_css, text_width, wrap_text +from verifier.visual_style import load_profile, contrast, presentation_style, require_supported_text, font_css, text_width, wrap_text, function_tree_roles class VisualStyleTests(unittest.TestCase): def test_palette_and_provisional_scope(self): @@ -20,6 +20,36 @@ def test_palette_and_provisional_scope(self): self.assertGreaterEqual(s["contrast"]["body_text"], 4.5) self.assertGreaterEqual(s["contrast"]["accent_text"], 4.5) + def test_tree_role_catalog_legacy_profiles_and_independent_resolution(self): + p=load_profile();saved=deepcopy(p);roles=function_tree_roles(p) + self.assertEqual(roles['pairs']['orange_on_violet']['fill_color'],'#e0dae6') + self.assertEqual(roles['pairs']['orange_on_violet']['frame_color'],'#894b25') + for pair in roles['pairs'].values(): + self.assertGreaterEqual(pair['contrast']['text'],4.5) + self.assertTrue(all(v>=3 for k,v in pair['contrast'].items() if k!='text')) + p['function_tree_roles']['pairs']['orange_on_violet']['connector']='blue.shade' + changed=function_tree_roles(p)['pairs']['orange_on_violet'] + self.assertEqual(changed['connector_color'],'#27456f') + self.assertEqual(changed['frame_color'],'#894b25') + self.assertEqual(changed['fill_color'],'#e0dae6') + legacy=deepcopy(saved);legacy.pop('function_tree_roles') + self.assertEqual(load_profile(legacy)['function_tree_roles'],saved['function_tree_roles']) + tokens=json.loads((ROOT/'assets/diagram-style/design-tokens.json').read_text()) + self.assertEqual(tokens['function_tree_implementation']['roles'],saved['function_tree_roles']) + self.assertEqual(load_profile(),saved) + + def test_tree_role_refusals_prevent_unknown_monochrome_or_low_contrast_pairs(self): + for defect in ('unknown_ref','unknown_pair','monochrome','role_tone','css','status','connector_contrast'): + p=load_profile();roles=p['function_tree_roles'];pair=roles['pairs']['orange_on_violet'] + if defect=='unknown_ref':pair['fill']='missing.tint' + elif defect=='unknown_pair':roles['root_pair']='missing' + elif defect=='monochrome':pair['fill']='tangerine.tint' + elif defect=='role_tone':pair['connector']='blue.tint' + elif defect=='css':pair['style']='anything' + elif defect=='status':roles['status']='universally_accepted' + else:p['palette']['families']['green']['shade']='#eeeeea' + with self.subTest(defect=defect),self.assertRaises(ValueError):load_profile(p) + def test_absolute_contrast_oracles(self): self.assertAlmostEqual(contrast("#000000", "#ffffff"), 21) self.assertEqual(contrast("#123456", "#123456"), 1) diff --git a/verifier/visual_projection.py b/verifier/visual_projection.py index 3f423b6..ef6a37f 100644 --- a/verifier/visual_projection.py +++ b/verifier/visual_projection.py @@ -10,7 +10,7 @@ from .engineering_views import check_views from .sysml import check_sysml -from .visual_style import ASSETS, load_profile, presentation_style, require_supported_text, text_width, wrap_text, contrast +from .visual_style import ASSETS, load_profile, presentation_style, require_supported_text, text_width, wrap_text, contrast, function_tree_roles from .visual_labels import apply_labels, load_labels VERSION = "cse-visual-presentation-1.0" @@ -78,7 +78,8 @@ def render_function_tree(projection, *, profile=None, body="paper", target_width if n['parent'] is not None: children[n['parent']].append(n['id']) root = next(n['id'] for n in nodes if n['parent'] is None) - families, roles = ['blue', 'tangerine', 'green', 'cyan', 'berry', 'amber'], {root: 'violet'} + role_profile = function_tree_roles(profile) + families, roles = role_profile['branch_pairs'], {root: role_profile['root_pair']} def assign(key, family): roles[key] = family for child in children[key]: @@ -185,7 +186,7 @@ def text(lines, x, baseline, fontsize, color, *, family='Space Grotesk', weight= else: first,last = edge_points[kids[0]]['points'],edge_points[kids[-1]]['points'] segments = [[first[0],first[1],first[2],last[-2]]] - color = style['ink'] if parent == root else palette['families'][roles[parent]]['shade'] + color = style['ink'] if parent == root else role_profile['pairs'][roles[parent]]['connector_color'] commands = ['M ' + ' L '.join(f'{x:g} {y:g}' for x,y in points) for points in segments] svg.append(f'') shared_routes.append({'parent':parent,'segments':segments,'color':color}) @@ -193,13 +194,13 @@ def text(lines, x, baseline, fontsize, color, *, family='Space Grotesk', weight= # Shared trunks are painted once; this path is the uniquely owned drop. points = edge['points'][-2:] path = 'M ' + ' L '.join(f'{x:g} {y:g}' for x,y in points) - svg.append(f'') + svg.append(f'') geometry = [] for node in nodes: key = node['id']; x,y = positions[key]; b = boxes[key] - family = palette['families'][roles[key]] - fill = family['tint'] if children[key] else style['fill'] - border = style['accent'] if key == root else family['shade'] + pair = role_profile['pairs'][roles[key]] + fill = pair['fill_color'] if children[key] else style['fill'] + border = pair['frame_color'] if contrast(style['ink'], fill) < 4.5 or min(contrast(border, fill), contrast(border, style['paper'])) < 3: raise ValueError('Insufficient contrast for local tree presentation roles') attrs = ' '.join(f'data-{k}="{escape(str(node[k]),quote=True)}"' for k in ('id','element','level','parent')) + f' data-native-name="{escape(node["text"],quote=True)}"' @@ -215,7 +216,7 @@ def text(lines, x, baseline, fontsize, color, *, family='Space Grotesk', weight= svg.append('
') geometry.append({'id':key,'x':x,'y':y,'width':b['width'],'height':b['height'], 'label_lines':b['lines'],'label_font_weight':b['weight'],'label_baseline':y+padding+size, - 'label_region_height':b['label_height'],'footer_lines':b['footer'],'palette_family':roles[key]}) + 'label_region_height':b['label_height'],'footer_lines':b['footer']}) svg.extend([text(['Branch colors identify documentary groups; lines show decomposition.'],48,height-32,18,style['ink'],role='legend'),'']) ledger = {'version':VERSION,'body':body,'target_width':target_width,'canvas_width':width,'canvas_height':height, 'claim_scope':CLAIM,'show_ids':show_ids,'display':wording,'geometry':geometry, @@ -224,7 +225,8 @@ def text(lines, x, baseline, fontsize, color, *, family='Space Grotesk', weight= 'layout':{'kind':'balanced-branches-with-stacked-leaves','padding':padding,'line_height':line_height, 'group_gap':48,'leaf_gap':18,'max_label_width':300, 'scope':'Measured advance-based wrapping; actual loaded glyph bounds require browser inspection.'}, - 'presentation_roles':{'root':'violet tint with tangerine shade key','branches':'selected palette families repeated within each first-level branch', + 'presentation_roles':{'root':'profile-selected contrasting pair','branches':'independent fill, frame/accent and connector references', + 'profile':role_profile,'node_pairings':roles, 'meaning':'Local navigation only; no universal engineering or status semantics','body_status':'provisional'}, 'font_metrics_sha256':_digest((ASSETS/'fonts/metrics.json').read_bytes()),'style':style} return '\n'.join(svg)+'\n',ledger @@ -240,7 +242,7 @@ def write_presentation(model_paths, output, *, view_id, profile=None, body="pape if report["status"] != "pass": return report projection = project_function_tree(report["inventory"], view_id) - validated_profile = load_profile(profile) if profile is None or isinstance(profile, (str, Path)) else deepcopy(profile) + validated_profile = load_profile(profile) validated_labels = load_labels(labels) svg, presentation = render_function_tree(projection, profile=validated_profile, body=body, target_width=target_width, show_ids=show_ids, labels=validated_labels) # Capture source bytes and verify parser pins before creating any output. diff --git a/verifier/visual_style.py b/verifier/visual_style.py index 337ed3d..e345328 100644 --- a/verifier/visual_style.py +++ b/verifier/visual_style.py @@ -42,7 +42,7 @@ def load_profile(path=None): Path(path or ASSETS / "profile.json").read_text(), object_pairs_hook=_unique, parse_constant=lambda value: (_ for _ in ()).throw(ValueError(f"Nonfinite {value}"))) expected = {"version", "name", "direction_status", "palette", "fonts", "body_variants", "numeric_proposal", "limits"} - if not isinstance(data, dict) or set(data) != expected or type(data["version"]) is not int or data["version"] != 1: + if not isinstance(data, dict) or not expected <= set(data) or set(data) - expected - {"function_tree_roles"} or type(data["version"]) is not int or data["version"] != 1: raise ValueError("Expected visual profile version 1 and exact documented fields") palette = data["palette"] if not isinstance(palette, dict) or set(palette) != {"paper", "ink", "light", "middle", "families"}: @@ -75,9 +75,65 @@ def load_profile(path=None): raise ValueError("Insufficient text contrast for a body variant") if contrast(palette["families"]["tangerine"]["shade"], palette["families"]["violet"]["tint"]) < 4.5: raise ValueError("Insufficient orange-on-violet text contrast") + if "function_tree_roles" not in data: + # Version-1 custom profiles inherit the explicit bundled local defaults. + bundled = json.loads((ASSETS / "profile.json").read_text(), object_pairs_hook=_unique) + data["function_tree_roles"] = deepcopy(bundled["function_tree_roles"]) + _tree_roles(data) return deepcopy(data) +def _palette_reference(palette, reference, tone): + if not isinstance(reference, str) or reference.count('.') != 1: + raise ValueError('Expected a named palette family/tone reference') + family, variant = reference.split('.') + if family not in palette['families'] or variant != tone: + raise ValueError('Unsupported palette family or role tone') + return palette['families'][family][variant] + + +def _tree_roles(data): + roles = data['function_tree_roles'] + if not isinstance(roles, dict) or set(roles) != {'status','root_pair','branch_pairs','shared_fanout','pairs'}: + raise ValueError('Expected explicit function-tree role fields') + if roles['status'] != 'slice_specific_proposal' or roles['shared_fanout'] != 'ink': + raise ValueError('Preserve scoped proposal status and neutral shared fanout') + pairs = roles['pairs'] + if not isinstance(pairs, dict) or not 1 <= len(pairs) <= 8 or any(not isinstance(key,str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,63}',key) for key in pairs): + raise ValueError('Expected bounded named presentation pairs') + branches = roles['branch_pairs'] + if not isinstance(branches,list) or not 1 <= len(branches) <= 8 or any(not isinstance(key,str) or key not in pairs for key in branches) or len(branches) != len(set(branches)): + raise ValueError('Unknown, repeated or invalid branch presentation pair') + if not isinstance(roles['root_pair'],str) or roles['root_pair'] not in pairs: + raise ValueError('Unknown root presentation pair') + palette = data['palette'] + resolved = {} + for name, pair in pairs.items(): + if not isinstance(pair,dict) or set(pair) != {'fill','frame','connector'}: + raise ValueError('Pair needs independent fill, frame and connector roles') + fill = _palette_reference(palette,pair['fill'],'tint') + frame = _palette_reference(palette,pair['frame'],'shade') + connector = _palette_reference(palette,pair['connector'],'shade') + if pair['fill'].split('.')[0] == pair['frame'].split('.')[0]: + raise ValueError('Header fill and frame must use contrasting palette families') + ratios = {'text':contrast(palette['ink'],fill),'frame_on_header':contrast(frame,fill), + 'frame_on_paper':contrast(frame,palette['paper']),'connector_on_paper':contrast(connector,palette['paper'])} + if ratios['text'] < 4.5 or min(ratios[k] for k in ratios if k != 'text') < 3: + raise ValueError('Insufficient text or nontext contrast for presentation pair') + # Light leaf bodies remain independent of the colored header fill. + for leaf in (palette['paper'],'#ffffff',_tint(palette['families']['violet']['base'])): + if contrast(frame,leaf) < 3: + raise ValueError('Insufficient frame contrast on a provisional leaf body') + resolved[name] = {**pair,'fill_color':fill,'frame_color':frame,'connector_color':connector,'contrast':ratios} + return {'status':roles['status'],'root_pair':roles['root_pair'],'branch_pairs':deepcopy(branches), + 'shared_fanout_color':palette['ink'],'pairs':resolved} + + +def function_tree_roles(profile=None): + """Resolve explicit local role references; never derive engineering categories.""" + return _tree_roles(load_profile(profile)) + + def _tint(color): return "#" + "".join(f"{math.floor(0.94 * 255 + 0.06 * int(color[i:i+2], 16) + 0.5):02x}" for i in (1, 3, 5)) From 395f2f83ee0c789511f3ad65430356352c6567a7 Mon Sep 17 00:00:00 2001 From: ajhcs <176340565+ajhcs@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:32:36 +0000 Subject: [PATCH 10/10] Restore selected compact-tree branch colors --- assets/diagram-style/design-tokens.json | 23 +++++++++++++++++++---- assets/diagram-style/profile.json | 21 ++++++++++++++++++--- docs/visual-presentation.md | 19 +++++++++++-------- tests/test_visual_projection.py | 3 ++- tests/test_visual_style.py | 13 ++++++++++--- verifier/visual_style.py | 2 -- 6 files changed, 60 insertions(+), 21 deletions(-) diff --git a/assets/diagram-style/design-tokens.json b/assets/diagram-style/design-tokens.json index 3f774f2..29e0277 100644 --- a/assets/diagram-style/design-tokens.json +++ b/assets/diagram-style/design-tokens.json @@ -508,9 +508,9 @@ "status": "slice_specific_proposal", "root_pair": "orange_on_violet", "branch_pairs": [ - "orange_on_violet", - "brick_on_cream", - "green_on_cool" + "blue_on_blue", + "orange_on_orange", + "green_on_green" ], "shared_fanout": "ink", "pairs": { @@ -528,9 +528,24 @@ "fill": "cyan.tint", "frame": "green.shade", "connector": "green.shade" + }, + "blue_on_blue": { + "fill": "blue.tint", + "frame": "blue.shade", + "connector": "blue.shade" + }, + "orange_on_orange": { + "fill": "tangerine.tint", + "frame": "tangerine.shade", + "connector": "tangerine.shade" + }, + "green_on_green": { + "fill": "green.tint", + "frame": "green.shade", + "connector": "green.shade" } } }, - "scope": "Independent header fill, frame/accent and connector references. Matching accent/drop defaults preserve branch traceability. Leaf body choices remain provisional." + "scope": "Current selected compact tree treatment restores same-family blue, tangerine and green branches with an orange-on-violet root. Independent fill, frame/accent and connector resolution and nondefault pair candidates remain available. No universal engineering meaning; leaf body choices remain provisional." } } diff --git a/assets/diagram-style/profile.json b/assets/diagram-style/profile.json index 4f0d1ad..de2f2e8 100644 --- a/assets/diagram-style/profile.json +++ b/assets/diagram-style/profile.json @@ -76,9 +76,9 @@ "status": "slice_specific_proposal", "root_pair": "orange_on_violet", "branch_pairs": [ - "orange_on_violet", - "brick_on_cream", - "green_on_cool" + "blue_on_blue", + "orange_on_orange", + "green_on_green" ], "shared_fanout": "ink", "pairs": { @@ -96,6 +96,21 @@ "fill": "cyan.tint", "frame": "green.shade", "connector": "green.shade" + }, + "blue_on_blue": { + "fill": "blue.tint", + "frame": "blue.shade", + "connector": "blue.shade" + }, + "orange_on_orange": { + "fill": "tangerine.tint", + "frame": "tangerine.shade", + "connector": "tangerine.shade" + }, + "green_on_green": { + "fill": "green.tint", + "frame": "green.shade", + "connector": "green.shade" } } } diff --git a/docs/visual-presentation.md b/docs/visual-presentation.md index ad9b0fc..a6bc036 100644 --- a/docs/visual-presentation.md +++ b/docs/visual-presentation.md @@ -29,23 +29,26 @@ Nodes wrap within a measured text region and fit their own content plus padding. | --- | --- | --- | | Square content-fit framing | Glyph-advance wrapping, bounded padding and height, no canvas-wide bars | Other notation-specific node shapes | | Useful partitions and side keys | Derived child-count cell; optional ID/level cell; a single outer frame | Domain-owned compartments | -| Selected palette shade/tint roles | Explicit orange/violet, brick/cream and green/cool header pairs; readable ink labels | All eight families remain tokens; no universal status meanings | +| Selected palette shade/tint roles | Orange/violet root; blue, tangerine and green shade/tint branches; readable ink labels | All eight families remain tokens; no universal status meanings | | Inter and Space Grotesk | Embedded Inter title and Space Grotesk labels/counts, with pinned measured advances | Wider glyph qualification | | Target-scale vector geometry | Editable text/SVG; declared display-pixel strokes; fresh vector raster export | Calibrated print and editor roundtrip | | Line and hatch samples | Documentary decomposition routes only; shared trunks have explicit parents | Dashed relation semantics and typed materials/equipment textures | | Drafted exterior illustrations | No invented physical geometry in this function view | Source-admitted physical glyph adapters | -White, paper and faint-tint leaf bodies remain provisional choices; the demonstration requests white explicitly. The profile resolves header fill, frame/accent and connector roles independently. Local defaults pair violet tint with tangerine shade, amber tint with brick shade, and cyan tint with green shade. Branch drops and local spines default to the frame/accent shade for traceability, while the shared fan-out stays neutral. These are retained contrasting-pair candidates and scoped implementation defaults, not newly invented universally approved combinations. Branch color is a local navigation role, repeated within each first-level branch. It is neither engineering status nor a selected universal palette mapping. Texture is deliberately absent: this function model declares no material or equipment classes that could give a hatch meaning. +White, paper and faint-tint leaf bodies remain provisional choices; the demonstration requests white explicitly. The current selected compact-tree treatment restores blue tint/blue shade, tangerine tint/tangerine shade and green tint/green shade branches, with the original orange-on-violet root. Branch drops and local spines match the branch frame/accent, while the shared fan-out stays neutral. This display choice carries no universal engineering or status meaning. Texture is deliberately absent: this function model declares no material or equipment classes that could give a hatch meaning. -`function_tree_roles` is an optional version-1 profile section containing a named pair catalog, `root_pair`, ordered `branch_pairs` and the neutral `shared_fanout` role. Each pair separately references `fill`, `frame` and `connector` as palette family/tone names. Equal frame/connector defaults preserve grouping without coupling their resolution. Header fill and frame must use different families; unknown references, unsupported tones, monochromatic header pairs and insufficient contrast are refused. Legacy version-1 custom profiles inherit the bundled explicit defaults. The resolved roles and per-node pair IDs accompany the output; they do not alter the semantic ledger or geometry. +`function_tree_roles` is an optional version-1 profile section containing a named pair catalog, `root_pair`, ordered `branch_pairs` and the neutral `shared_fanout` role. Each pair separately references `fill`, `frame` and `connector` as palette family/tone names. Matching frame/connector defaults preserve grouping without coupling their resolution. Same-family and cross-family pairs are both permitted when they pass contrast checks. Unknown references, unsupported tones and insufficient contrast are refused. Legacy version-1 custom profiles inherit the bundled explicit defaults. Resolved roles and per-node pair IDs accompany the output without changing the semantic ledger or geometry. -| Local pair | Header fill | Frame/accent | Connector default | +| Selected local role | Header fill | Frame/accent | Connector default | | --- | --- | --- | --- | -| Orange on violet | Violet tint `#e0dae6` | Tangerine shade `#894b25` | Tangerine shade `#894b25` | -| Brick on cream | Amber tint `#ece1ce` | Brick shade `#7f362f` | Brick shade `#7f362f` | -| Green on cool | Cyan tint `#d4e3e7` | Green shade `#315e43` | Green shade `#315e43` | +| Root: orange on violet | Violet tint `#e0dae6` | Tangerine shade `#894b25` | Tangerine shade `#894b25` | +| Blue branch | Blue tint `#d2dae6` | Blue shade `#27456f` | Blue shade `#27456f` | +| Tangerine branch | Tangerine tint `#eedcd1` | Tangerine shade `#894b25` | Tangerine shade `#894b25` | +| Green branch | Green tint `#d5e2da` | Green shade `#315e43` | Green shade `#315e43` | -All text fields require at least4.5:1 contrast. Header frames and connectors require at least3:1 against their actual header or paper fields, and frames must also pass on every provisional leaf body. These arithmetic gates supplement actual pixel review of the contrasting pairings; they do not select engineering meanings or guarantee visual acceptance. +The previous cross-family branch treatment was declined and is not the default. Its brick/cream and green/cool pair definitions remain available as nondefault candidates; the rejected selection also remains in Git history. Their presence in the catalog establishes no visual acceptance. + +All text fields require at least 4.5:1 contrast. Header frames and connectors require at least 3:1 against their actual header or paper fields, and frames must also pass on every provisional leaf body. These arithmetic checks remain required for same-family and cross-family treatments. They supplement actual pixel review without selecting engineering meanings or guaranteeing visual acceptance. Text layout uses `fonts/metrics.json`, measured from the pinned WOFF2 faces with Canvas `measureText` at 1000px and tied to each face SHA-256. Advances scale to the selected font size; normal glyph shapes and spaces are retained. This particular layout disables kerning and ligatures consistently in measurement and SVG, and requests geometric-precision text rendering to retain fractional advances at small sizes. It applies no letter spacing, fitting transforms or character substitution. Soft line breaks concatenate to the exact authored string, including spaces. Actual browser glyph bounds, face identity and target-scale clearance still need checking for each qualified candidate; advance sums alone are not visual acceptance. diff --git a/tests/test_visual_projection.py b/tests/test_visual_projection.py index c7de85b..7c17363 100644 --- a/tests/test_visual_projection.py +++ b/tests/test_visual_projection.py @@ -173,6 +173,7 @@ def test_profile_pairs_change_only_colors_without_model_or_geometry_changes(self first,ledger=render_function_tree(projection,body='white') p=load_profile();pair=p['function_tree_roles']['pairs']['orange_on_violet'] pair['fill']='cyan.tint';pair['connector']='blue.shade' + p['function_tree_roles']['pairs']['blue_on_blue']['connector']='red.shade' second,changed=render_function_tree(projection,body='white',profile=p) self.assertNotEqual(first,second) def non_color(svg): @@ -189,7 +190,7 @@ def non_color(svg): self.assertEqual(frame.attrib['fill'],'#d4e3e7') self.assertEqual(frame.attrib['stroke'],'#894b25') drop=root.find("svg:path[@data-child='F1']",ns) - self.assertEqual(drop.attrib['stroke'],'#27456f') + self.assertEqual(drop.attrib['stroke'],'#7f362f') def test_shared_parent_trunk_is_single_neutral_route_with_explicit_child_drops(self): nodes,inventory=fixture();projection=project(nodes,inventory) diff --git a/tests/test_visual_style.py b/tests/test_visual_style.py index 5f5adb3..3ff4a35 100644 --- a/tests/test_visual_style.py +++ b/tests/test_visual_style.py @@ -24,6 +24,13 @@ def test_tree_role_catalog_legacy_profiles_and_independent_resolution(self): p=load_profile();saved=deepcopy(p);roles=function_tree_roles(p) self.assertEqual(roles['pairs']['orange_on_violet']['fill_color'],'#e0dae6') self.assertEqual(roles['pairs']['orange_on_violet']['frame_color'],'#894b25') + self.assertEqual(roles['branch_pairs'],['blue_on_blue','orange_on_orange','green_on_green']) + for name,family in [('blue_on_blue','blue'),('orange_on_orange','tangerine'),('green_on_green','green')]: + self.assertEqual(roles['pairs'][name]['fill_color'],p['palette']['families'][family]['tint']) + self.assertEqual(roles['pairs'][name]['frame_color'],p['palette']['families'][family]['shade']) + self.assertEqual(roles['pairs'][name]['connector_color'],p['palette']['families'][family]['shade']) + self.assertNotIn('brick_on_cream',roles['branch_pairs']) + self.assertNotIn('green_on_cool',roles['branch_pairs']) for pair in roles['pairs'].values(): self.assertGreaterEqual(pair['contrast']['text'],4.5) self.assertTrue(all(v>=3 for k,v in pair['contrast'].items() if k!='text')) @@ -38,12 +45,12 @@ def test_tree_role_catalog_legacy_profiles_and_independent_resolution(self): self.assertEqual(tokens['function_tree_implementation']['roles'],saved['function_tree_roles']) self.assertEqual(load_profile(),saved) - def test_tree_role_refusals_prevent_unknown_monochrome_or_low_contrast_pairs(self): - for defect in ('unknown_ref','unknown_pair','monochrome','role_tone','css','status','connector_contrast'): + def test_tree_role_refusals_prevent_unknown_or_low_contrast_pairs(self): + for defect in ('unknown_ref','unknown_pair','header_contrast','role_tone','css','status','connector_contrast'): p=load_profile();roles=p['function_tree_roles'];pair=roles['pairs']['orange_on_violet'] if defect=='unknown_ref':pair['fill']='missing.tint' elif defect=='unknown_pair':roles['root_pair']='missing' - elif defect=='monochrome':pair['fill']='tangerine.tint' + elif defect=='header_contrast':p['palette']['families']['blue']['tint']=p['palette']['families']['blue']['shade'] elif defect=='role_tone':pair['connector']='blue.tint' elif defect=='css':pair['style']='anything' elif defect=='status':roles['status']='universally_accepted' diff --git a/verifier/visual_style.py b/verifier/visual_style.py index e345328..4a2e28e 100644 --- a/verifier/visual_style.py +++ b/verifier/visual_style.py @@ -114,8 +114,6 @@ def _tree_roles(data): fill = _palette_reference(palette,pair['fill'],'tint') frame = _palette_reference(palette,pair['frame'],'shade') connector = _palette_reference(palette,pair['connector'],'shade') - if pair['fill'].split('.')[0] == pair['frame'].split('.')[0]: - raise ValueError('Header fill and frame must use contrasting palette families') ratios = {'text':contrast(palette['ink'],fill),'frame_on_header':contrast(frame,fill), 'frame_on_paper':contrast(frame,palette['paper']),'connector_on_paper':contrast(connector,palette['paper'])} if ratios['text'] < 4.5 or min(ratios[k] for k in ratios if k != 'text') < 3: