forked from 1b-agent/opencadstudio-upstream
Add named parameters, UI scale setting, OPTIONS/OP dispatch, and help manual
Named parameters (docs/named_parameters_design.md, stages 3-4): - ParameterTable (src/scene/named_parameters.rs): a document-wide table of named formulas, each resolved through a shared expression evaluator with cross-parameter references, cycle detection, and division-by-zero/parse error reporting. SketchConstraint::driving_param becomes DrivingValue (Literal | Named) instead of a bare f64, resolved at solve time by sketch_solve::build_constraint via Scene::named_parameters. - Persistence (src/scene/named_parameters_persist.rs): same lazy XRecord model sketch_persist.rs already established, synced around open/save. Bumps the sketch-constraint XRecord's own format version (1 -> 2) since DrivingValue's shape doesn't line up with a version-1 blob's bytes. - PARAMETERS command and its editor (src/ui/window/named_parameters.rs): add/remove/edit rows, live validation, duplicate-name detection: Apply defines the table and re-solves every constraint scope with a named-parameter-driven constraint (command_driver.rs). Distance/Angle constraint prompts (modules/draw/constrain/value.rs) now accept either a literal or an existing parameter's name, mirroring how the constraint's own value prompt already worked. - Undo scope-down carried over from this project's own established pattern (constraint-system stage 9): the geometry an Apply moves is one undo step, but the parameter table edit itself has no undo entry yet — a documented follow-up, not a silent gap. UI scale setting (src/app/settings.rs, app/mod.rs, ui/window/options.rs): a whole-interface scale percentage (50-200%), wired directly into iced's own per-window scale_factor hook rather than per-panel font-size plumbing, so it applies immediately to every panel, dialog, and the ribbon at once. Persisted alongside the existing lineweight-display-scale setting. OPTIONS/OP command dispatch (app/commands/view.rs, app/commands/mod.rs): OPTIONS was registered for autocomplete but had no dispatch arm — the Start-tab welcome screen's own button was the only way to reach it. Found live-testing the UI-scale setting, unrelated to that feature itself. "OP" is AutoCAD's own short alias. help/: a new self-contained static user-help manual (manual.md as the authored source, tools/build_help.py splits it into the committed static HTML pages plus search index) — not yet wired into the application. 64 new/updated tests across the named-parameters and constraint-prompt changes. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
e2d39b049d
commit
7cddf31bcb
47 files changed
+4136
-52
No files matched your search
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the static OpenCAD Studio HTML help system from help/manual.md."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HELP = Path(__file__).resolve().parents[1]
|
||||
SOURCE = HELP / "manual.md"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Page:
|
||||
filename: str
|
||||
title: str
|
||||
summary: str
|
||||
|
||||
|
||||
PAGES = (
|
||||
Page("getting-started.html", "Getting Started", "Begin a drawing safely and learn how to use this manual."),
|
||||
Page("files-drawings.html", "Files and Drawings", "Open, save, recover, and move drawings with their dependencies."),
|
||||
Page("workspace-navigation.html", "Workspace and Navigation", "Work with spaces, views, selection, and object snaps."),
|
||||
Page("create-edit-geometry.html", "Create and Edit Geometry", "Draw, modify, grip-edit, erase, and restore geometry."),
|
||||
Page("layers-properties.html", "Layers and Properties", "Organize objects and control their inherited appearance."),
|
||||
Page("blocks-references.html", "Blocks and References", "Create reusable content, arrays, attributes, and file references."),
|
||||
Page("annotation-dimensions.html", "Annotation and Dimensions", "Add readable text, dimensions, leaders, and scaled annotation."),
|
||||
Page("layouts-plotting.html", "Layouts and Plotting", "Prepare paper-space sheets and verify plotted output."),
|
||||
Page("3d-modeling.html", "3D Modeling and Visualization", "Create and inspect solids, surfaces, materials, and visual styles."),
|
||||
Page("parametric-constraints.html", "Parametric Constraints", "Capture geometric intent and diagnose solve or history issues."),
|
||||
Page("commands-shortcuts.html", "Commands and Shortcuts", "Use prompts, autocomplete, aliases, and keyboard conventions."),
|
||||
Page("automation-plugins.html", "Automation and Plugins", "Control drawings programmatically and extend the native app."),
|
||||
Page("troubleshooting-recovery.html", "Troubleshooting and Recovery", "Diagnose file, geometry, rendering, and history problems."),
|
||||
)
|
||||
|
||||
|
||||
def slug(text: str) -> str:
|
||||
value = re.sub(r"[^\w\s-]", "", text.lower(), flags=re.UNICODE)
|
||||
value = re.sub(r"[\s_]+", "-", value)
|
||||
return value.strip("-")
|
||||
|
||||
|
||||
def split_chapters(markdown: str) -> dict[str, str]:
|
||||
chapters: dict[str, list[str]] = {}
|
||||
current: list[str] | None = None
|
||||
for line in markdown.splitlines():
|
||||
match = re.match(r"^## (.+)$", line)
|
||||
if match:
|
||||
title = match.group(1).strip()
|
||||
if title in chapters:
|
||||
raise RuntimeError(f"Duplicate chapter: {title}")
|
||||
current = [line]
|
||||
chapters[title] = current
|
||||
elif current is not None:
|
||||
current.append(line)
|
||||
return {title: "\n".join(lines) for title, lines in chapters.items()}
|
||||
|
||||
|
||||
def pandoc(markdown: str) -> str:
|
||||
process = subprocess.run(
|
||||
["pandoc", "--from=gfm", "--to=html5", "--wrap=none"],
|
||||
input=markdown,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return process.stdout.strip()
|
||||
|
||||
|
||||
def headings(markdown: str) -> list[tuple[str, str]]:
|
||||
output: list[tuple[str, str]] = []
|
||||
for line in markdown.splitlines():
|
||||
match = re.match(r"^### (.+)$", line)
|
||||
if match:
|
||||
title = match.group(1).strip()
|
||||
output.append((slug(title), title))
|
||||
return output
|
||||
|
||||
|
||||
def sidebar(current: Page | None, chapters: dict[str, str]) -> str:
|
||||
parts = ['<nav id="help-sidebar" class="sidebar" aria-label="Help chapters">']
|
||||
index_class = ' class="current-topic"' if current is None else ""
|
||||
parts.append(f'<div{index_class}><a class="chapter-link" href="index.html">Help overview</a></div>')
|
||||
for page in PAGES:
|
||||
classes = ' class="current-topic"' if page == current else ""
|
||||
opened = " open" if page == current else ""
|
||||
links = "".join(
|
||||
f'<li><a href="{page.filename}#{anchor}">{html.escape(title)}</a></li>'
|
||||
for anchor, title in headings(chapters[page.title])
|
||||
)
|
||||
parts.append(
|
||||
f'<details{classes}{opened}><summary><a href="{page.filename}">{html.escape(page.title)}</a></summary>'
|
||||
f'<ul>{links}</ul></details>'
|
||||
)
|
||||
parts.append("</nav>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def overview() -> str:
|
||||
cards = "".join(
|
||||
f'<a class="chapter-card" href="{page.filename}"><strong>{html.escape(page.title)}</strong>'
|
||||
f'<span>{html.escape(page.summary)}</span></a>'
|
||||
for page in PAGES
|
||||
)
|
||||
return f"""
|
||||
<section class="hero">
|
||||
<p class="eyebrow">OpenCAD Studio User Manual</p>
|
||||
<h2>Find the workflow, command, or concept you need</h2>
|
||||
<p>Learn how to create and edit drawings, exchange DWG and DXF files, prepare layouts, model in 3D, use constraints, automate work, and recover from common problems.</p>
|
||||
</section>
|
||||
<div class="quick-paths" aria-label="Common help paths">
|
||||
<a href="getting-started.html#a-first-drawing">Create a first drawing</a>
|
||||
<a href="files-drawings.html#recovery-and-backups">Recover a drawing</a>
|
||||
<a href="troubleshooting-recovery.html#report-a-reproducible-problem">Report a problem</a>
|
||||
</div>
|
||||
<h2 id="manual-chapters">Manual chapters</h2>
|
||||
<div class="chapter-grid">{cards}</div>
|
||||
""".strip()
|
||||
|
||||
|
||||
def shell(page: Page | None, title: str, body: str, chapters: dict[str, str]) -> str:
|
||||
previous_link = ""
|
||||
next_link = ""
|
||||
if page is not None:
|
||||
index = PAGES.index(page)
|
||||
previous = PAGES[index - 1] if index else None
|
||||
following = PAGES[index + 1] if index + 1 < len(PAGES) else None
|
||||
previous_link = (
|
||||
f'<a rel="prev" href="{previous.filename}">← {html.escape(previous.title)}</a>'
|
||||
if previous else '<a rel="prev" href="index.html">← Help overview</a>'
|
||||
)
|
||||
next_link = (
|
||||
f'<a rel="next" href="{following.filename}">{html.escape(following.title)} →</a>'
|
||||
if following else ""
|
||||
)
|
||||
elif PAGES:
|
||||
next_link = f'<a rel="next" href="{PAGES[0].filename}">{html.escape(PAGES[0].title)} →</a>'
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="OpenCAD Studio user manual — {html.escape(title)}">
|
||||
<title>{html.escape(title)} — OpenCAD Studio Help</title>
|
||||
<link rel="stylesheet" href="assets/css/core.css">
|
||||
<link rel="stylesheet" href="assets/css/opencad.css">
|
||||
<script src="search-index.js" defer></script>
|
||||
<script src="assets/js/help.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<button class="nav-toggle" type="button" aria-controls="help-sidebar" aria-expanded="true" title="Toggle chapter navigation"><span aria-hidden="true">☰</span><span class="sr-only">Toggle chapter navigation</span></button>
|
||||
<a class="brand" href="index.html">OpenCAD Studio Help</a>
|
||||
<div class="search-shell">
|
||||
<label class="sr-only" for="help-search">Search help</label>
|
||||
<input id="help-search" class="help-search" type="search" role="combobox" placeholder="Search commands and topics" autocomplete="off" aria-autocomplete="list" aria-controls="search-results" aria-expanded="false">
|
||||
<span class="search-key" aria-hidden="true">⌘K</span>
|
||||
<ul id="search-results" class="search-results" role="listbox" hidden></ul>
|
||||
</div>
|
||||
</header>
|
||||
{sidebar(page, chapters)}
|
||||
<main id="main-content">
|
||||
<div class="content-wrap">
|
||||
<p class="page-kicker">OpenCAD Studio User Manual</p>
|
||||
<h1 class="content-title">{html.escape(title)}</h1>
|
||||
{body}
|
||||
<nav class="page-navigation" aria-label="Previous and next chapter">{previous_link}{next_link}</nav>
|
||||
<p class="date-footer">OpenCAD Studio Help · Generated from <code>help/manual.md</code></p>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class TextExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.parts: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if data.strip():
|
||||
self.parts.append(data.strip())
|
||||
|
||||
|
||||
def search_entries(chapters: dict[str, str]) -> list[dict[str, str]]:
|
||||
entries: list[dict[str, str]] = []
|
||||
for page in PAGES:
|
||||
chapter = chapters[page.title]
|
||||
sections = re.split(r"(?=^### )", chapter, flags=re.MULTILINE)
|
||||
for section in sections:
|
||||
match = re.match(r"^### (.+)$", section.splitlines()[0] if section.splitlines() else "")
|
||||
if not match:
|
||||
continue
|
||||
title = match.group(1).strip()
|
||||
extractor = TextExtractor()
|
||||
extractor.feed(pandoc(section))
|
||||
entries.append({
|
||||
"title": title,
|
||||
"chapter": page.title,
|
||||
"url": f"{page.filename}#{slug(title)}",
|
||||
"text": " ".join(extractor.parts),
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def generated_outputs() -> dict[str, str]:
|
||||
markdown = SOURCE.read_text(encoding="utf-8")
|
||||
chapters = split_chapters(markdown)
|
||||
expected = {page.title for page in PAGES}
|
||||
if set(chapters) != expected:
|
||||
raise RuntimeError(
|
||||
f"Manual chapter mapping mismatch. Missing: {sorted(expected - set(chapters))}; "
|
||||
f"extra: {sorted(set(chapters) - expected)}"
|
||||
)
|
||||
|
||||
output = {"index.html": shell(None, "Help overview", overview(), chapters)}
|
||||
for page in PAGES:
|
||||
chapter_body = "\n".join(chapters[page.title].splitlines()[1:]).lstrip()
|
||||
output[page.filename] = shell(page, page.title, pandoc(chapter_body), chapters)
|
||||
index_json = json.dumps(search_entries(chapters), ensure_ascii=False, separators=(",", ":"))
|
||||
output["search-index.js"] = f"window.OCS_HELP_INDEX = {index_json};\n"
|
||||
return output
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="Fail if generated output is stale.")
|
||||
args = parser.parse_args()
|
||||
output = generated_outputs()
|
||||
|
||||
if args.check:
|
||||
stale = [name for name, content in output.items() if not (HELP / name).is_file() or (HELP / name).read_text(encoding="utf-8") != content]
|
||||
if stale:
|
||||
print("Stale generated help: " + ", ".join(stale), file=sys.stderr)
|
||||
return 1
|
||||
print(f"Generated help is current ({len(output) - 1} pages plus search index).")
|
||||
return 0
|
||||
|
||||
for name, content in output.items():
|
||||
(HELP / name).write_text(content, encoding="utf-8")
|
||||
print(f"Generated {len(output) - 1} pages and search-index.js in {HELP}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate local links, anchors, assets, context targets, and basic HTML structure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
HELP = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class Document(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.ids: set[str] = set()
|
||||
self.links: list[str] = []
|
||||
self.assets: list[str] = []
|
||||
self.has_main = False
|
||||
self.has_title = False
|
||||
self.images_without_alt = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
values = dict(attrs)
|
||||
if values.get("id"):
|
||||
self.ids.add(values["id"] or "")
|
||||
if tag == "a" and values.get("href"):
|
||||
self.links.append(values["href"] or "")
|
||||
if tag in {"img", "script", "link"}:
|
||||
target = values.get("src") or values.get("href")
|
||||
if target:
|
||||
self.assets.append(target)
|
||||
if tag == "img" and "alt" not in values:
|
||||
self.images_without_alt += 1
|
||||
self.has_main |= tag == "main"
|
||||
self.has_title |= tag == "title"
|
||||
|
||||
|
||||
def load_documents() -> dict[str, Document]:
|
||||
documents: dict[str, Document] = {}
|
||||
for path in sorted(HELP.glob("*.html")):
|
||||
document = Document()
|
||||
document.feed(path.read_text(encoding="utf-8"))
|
||||
documents[path.name] = document
|
||||
return documents
|
||||
|
||||
|
||||
def validate_target(source: str, target: str, documents: dict[str, Document], errors: list[str]) -> None:
|
||||
if target.startswith(("http:", "https:", "mailto:", "javascript:")):
|
||||
return
|
||||
filename, _, fragment = target.partition("#")
|
||||
filename = unquote(filename) or source
|
||||
destination = HELP / filename
|
||||
if not destination.is_file():
|
||||
errors.append(f"{source}: missing target {target}")
|
||||
return
|
||||
if fragment and destination.suffix == ".html":
|
||||
document = documents.get(destination.name)
|
||||
if document is None or fragment not in document.ids:
|
||||
errors.append(f"{source}: missing anchor {target}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
documents = load_documents()
|
||||
errors: list[str] = []
|
||||
if not documents:
|
||||
errors.append("No generated HTML pages found.")
|
||||
|
||||
for name, document in documents.items():
|
||||
if not document.has_main:
|
||||
errors.append(f"{name}: missing <main>")
|
||||
if not document.has_title:
|
||||
errors.append(f"{name}: missing <title>")
|
||||
if document.images_without_alt:
|
||||
errors.append(f"{name}: {document.images_without_alt} image(s) missing alt")
|
||||
for target in document.links + document.assets:
|
||||
validate_target(name, target, documents, errors)
|
||||
|
||||
context = json.loads((HELP / "context-map.json").read_text(encoding="utf-8"))
|
||||
for group in ("commands", "surfaces"):
|
||||
for key, target in context.get(group, {}).items():
|
||||
validate_target(f"context-map.json:{group}.{key}", target, documents, errors)
|
||||
|
||||
if errors:
|
||||
print("Help validation failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
links = sum(len(document.links) for document in documents.values())
|
||||
print(f"Validated {len(documents)} pages, {links} links, and {sum(len(context.get(group, {})) for group in ('commands', 'surfaces'))} context targets.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
Reference in new issue
Block a user