@litfamily/litmuse 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9 — 2026-11-06
4
+
5
+ - lit-schematic QA catches three more drawing faults: a symbol that touches no line (a vent floating above a tank roof), a label pressed against a line or a circle with no air around it, and a line drawn straight through an open symbol. Tank tags can sit to the right of the tank. The legend rows have more room, and the dosing-skid example was relaid so the relief line no longer crosses pump A.
6
+ - lit-schematic arrows can point any way, tags of vertical inline parts sit beside the pipe instead of on it, and the PSV symbol is redrawn.
7
+
8
+ ## 0.1.8 — 2026-11-04
9
+
10
+ - `install --approve` no longer prints a stale waiting list computed before approving. It now says it is approving the capabilities, then reports what was approved and lists only what is really still waiting (or says none are). Without `--approve` the waiting list stays as it was.
11
+
3
12
  ## 0.1.7 — 2026-11-03
4
13
 
5
14
  - Muse keeps the cached conversation across turns more often. The once-a-day suggestion to run `lit adhd` used to sit in the session-start text, which Muse rebuilds on every resume and places before the whole conversation; the line appeared on one resume and vanished on the next, so the cached history behind it was thrown away twice. The suggestion now rides on the first plain prompt instead, once per day as before and never while a route is live, and the session-start text stays the same for the whole session.
package/README.md CHANGED
@@ -11,11 +11,11 @@
11
11
  <a href="https://github.com/wjgoarxiv/litmuse/stargazers"><img src="https://img.shields.io/github/stars/wjgoarxiv/litmuse?style=flat" alt="GitHub stars" /></a>
12
12
  </p>
13
13
 
14
- <p align="center"><a href="#install">Install</a> &nbsp; <a href="#quick-start">Quick start</a> &nbsp; <a href="#the-status-card">Status card</a> &nbsp; <a href="#automatic-handoff">Handoff</a> &nbsp; <a href="#safety-and-uninstall">Safety</a> &nbsp; <a href="./README_ko-KR.md">한국어</a></p>
14
+ <p align="center"><a href="#in-the-muse-app-too">Muse app</a> &nbsp; <a href="#install">Install</a> &nbsp; <a href="#quick-start">Quick start</a> &nbsp; <a href="#the-status-card">Status card</a> &nbsp; <a href="#automatic-handoff">Handoff</a> &nbsp; <a href="#safety-and-uninstall">Safety</a> &nbsp; <a href="./README_ko-KR.md">한국어</a></p>
15
15
 
16
16
  ## What a session looks like
17
17
 
18
- <p align="center"><img src="./docs/assets/readme/demo.webp" width="960" alt="Terminal capture of three real commands on this Mac. muse --version prints Muse Code 1.4.11. litmuse status --card shows the lit-plan route active with plan 0 of 2 and the next open row. litmuse models lists the muse-spark models from the host catalog." /></p>
18
+ <p align="center"><img src="./docs/assets/readme/demo.webp" width="960" alt="Terminal card of three real commands in a test project. muse --version prints Muse Code 1.4.14. litmuse status --card shows the lit-plan route active for 30s with plan 0/2 in plans/todo-app.md and next row 1, Add the shout flag. litmuse models lists muse-spark-1.3 (default) and muse-spark-1.2 with their effort levels." /></p>
19
19
 
20
20
  Stripped to three lines, a planning session goes like this:
21
21
 
@@ -44,6 +44,14 @@ LitMuse gives each session a routine. Start a prompt with `lit` and the plugin's
44
44
 
45
45
  Muse still runs the session and picks the model. LitMuse adds the way of working, and it lives in plain files you can read.
46
46
 
47
+ ## In the Muse app too
48
+
49
+ Meta ships two Muse products. Muse Code is the terminal agent this README is about. The Muse app is the desktop agent with a chat window, side chats, notes and tasks. LitFamily also works inside the Muse app: its lit skills load there the same way, and a reply opens with the same ignition mark you see in the terminal.
50
+
51
+ The capture below is one new side chat in the Muse app. The prompt asks `/lit-brainstorm` for names for a small open-source note tool you use from the terminal. The answer opens with `🔥 LIT IGNITED · lit-brainstorm 🔥`, sets weighted criteria before scoring, first drops the names that are already taken (`jot`, `memo`, `mnemo`, `scratch`, `margin`), and then shows a scored table of the top ten.
52
+
53
+ <p align="center"><img src="./docs/assets/muse-app/lit-brainstorm.webp" width="420" alt="One side chat in the Muse desktop app titled 터미널 메모 도구 이름 추천. The user prompt reads /lit-brainstorm followed by a Korean request for name ideas and scores for a small open-source terminal note tool. The reply starts with 🔥 LIT IGNITED · lit-brainstorm 🔥, says 32 candidates were drawn and taken names were removed first, lists weighted criteria on a 100-point scale, names jot, memo, mnemo, scratch and margin as dropped, and shows a top-10 score table led by termemo 87%, jotlet 80% and memlet 80%." /></p>
54
+
47
55
  ## Install
48
56
 
49
57
  You need Node.js 20 or newer and Muse Code, signed in once. In a terminal, run:
@@ -109,15 +117,15 @@ Read the card from top to bottom. The first line names the workflow and how long
109
117
 
110
118
  At rest the card reports no active route:
111
119
 
112
- <p align="center"><img src="./docs/assets/card/idle.webp" width="960" alt="The status card with no active route in this workspace." /></p>
120
+ <p align="center"><img src="./docs/assets/card/idle.webp" width="960" alt="Terminal card of litmuse status --card with no route running: LIT, no active route in this workspace, plan 0/2 in plans/todo-app.md, next row 1, Add the shout flag." /></p>
113
121
 
114
122
  While a plan runs, the card names the route, the plan file, and the next open row:
115
123
 
116
- <p align="center"><img src="./docs/assets/card/working.webp" width="960" alt="The status card while the lit-plan route is active, naming the plan file with 0 of 2 rows ticked and the next open row." /></p>
124
+ <p align="center"><img src="./docs/assets/card/working.webp" width="960" alt="Terminal card of litmuse status --card while lit-plan runs: LIT, lit-plan, active 30s, one lit-plan thread running, plan 0/2 in plans/todo-app.md, next row 1, Add the shout flag." /></p>
117
125
 
118
126
  Ticking a row moves the count and the next-row pointer:
119
127
 
120
- <p align="center"><img src="./docs/assets/card/after.webp" width="960" alt="The same card after the first row is ticked: 1 of 2 with the second row named next." /></p>
128
+ <p align="center"><img src="./docs/assets/card/after.webp" width="960" alt="The same card after row 1 is ticked: plan 1/2 in plans/todo-app.md, next row 2, Write the tests." /></p>
121
129
 
122
130
  The card reads the hook-kept state under `~/.local/state/litmuse/`, outside the project.
123
131
 
@@ -127,17 +135,17 @@ A long session fills the model's context, and when Muse compacts it, detail drop
127
135
 
128
136
  The hook answers with one recorded line:
129
137
 
130
- <p align="center"><img src="./docs/assets/handoff/saved.webp" width="960" alt="The PreCompact hook output line recording that it saved .litmuse/handoff/latest.md." /></p>
138
+ <p align="center"><img src="./docs/assets/handoff/saved.webp" width="960" alt="Terminal card of muse plugins hook test running the PreCompact hook: status completed, exit code 0, system message LitMuse saved .litmuse/handoff/latest.md." /></p>
131
139
 
132
140
  The snapshot names the trigger, the session, the route, the plan with its open rows, and recent ledger lines:
133
141
 
134
- <p align="center"><img src="./docs/assets/handoff/latest.webp" width="960" alt="The handoff snapshot: trigger, session, the lit-plan route, the plan with 0 ticked and 2 open rows, and recent ledger lines." /></p>
142
+ <p align="center"><img src="./docs/assets/handoff/latest.webp" width="960" alt="Terminal card of cat .litmuse/handoff/latest.md: the LitMuse compaction snapshot with trigger manual, session readme-demo-01, route lit-plan, plan plans/todo-app.md with 0 ticked and 2 open, the next open row, and two recent ledger lines for read_file and write_file." /></p>
135
143
 
136
144
  ## What you can make
137
145
 
138
146
  Four real outputs rendered on a Mac during the quality parity round, each opened, read, and fixed until it looked right:
139
147
 
140
- <p align="center"><img src="./docs/assets/parity/lit-adhd-brief.png" width="960" alt="The lit-adhd brief in a real terminal: rainbow headline, one active session card with goal and work time, tools, the finished commit, and a boxed recommended next step." /></p>
148
+ <p align="center"><img src="./docs/assets/parity/lit-adhd-brief.png" width="960" alt="Terminal card of litmuse rewind, the lit-adhd brief: a rainbow headline with 2 sessions and 0 waiting on you, a table of the two ended sessions in todo-app, the roadmap gauge for plans/todo-app.md at 1/2, and a boxed recommended next step to start the plan." /></p>
141
149
 
142
150
  `lit-adhd` briefs your live sessions: session cards with status badges, an ended table, and a boxed recommended step — colour on TTY, no internal field names leaking.
143
151
 
@@ -145,7 +153,7 @@ Four real outputs rendered on a Mac during the quality parity round, each opened
145
153
 
146
154
  `lit-pptx` compiles real decks from data: this one charts the port's own test-suite growth, wave by wave.
147
155
 
148
- <p align="center"><img src="./docs/assets/parity/lit-schematic-pid.png" width="960" alt="A lit-schematic P&ID: a sodium hypochlorite dosing skid with tank TK-101, duty and standby dosing pumps, a relief-valve spillback, instrument bubbles, line numbers, a drawn-symbol legend, and title block EX-PID-001." /></p>
156
+ <p align="center"><img src="./docs/assets/parity/lit-schematic-pid.png" width="960" alt="A lit-schematic P&ID of a hypochlorite dosing skid, marked as a worked example with invented values: tank TK-101 with LT-101 and LIA-101, isolation valve HV-101 and strainer STR-101, duty and standby pumps P-101A and P-101B with check valves, PSV-101 set at 8 barg relieving back to the tank, PI-101 and FIT-101 on the discharge, line numbers, a legend, and title block EX-PID-001 rev B." /></p>
149
157
 
150
158
  `lit-schematic` draws QA-checked P&IDs: a dosing skid with real symbols, a drawn legend, line numbers, and a title block — seven look rounds until every label read cleanly.
151
159
 
package/README_ko-KR.md CHANGED
@@ -11,11 +11,11 @@
11
11
  <a href="https://github.com/wjgoarxiv/litmuse/stargazers"><img src="https://img.shields.io/github/stars/wjgoarxiv/litmuse?style=flat" alt="GitHub stars" /></a>
12
12
  </p>
13
13
 
14
- <p align="center"><a href="#설치">설치</a> &nbsp; <a href="#빠른-시작">빠른 시작</a> &nbsp; <a href="#상태-카드">상태 카드</a> &nbsp; <a href="#자동-핸드오프">핸드오프</a> &nbsp; <a href="#안전과-제거">안전</a> &nbsp; <a href="./README.md">English</a></p>
14
+ <p align="center"><a href="#muse-앱에서도">Muse 앱</a> &nbsp; <a href="#설치">설치</a> &nbsp; <a href="#빠른-시작">빠른 시작</a> &nbsp; <a href="#상태-카드">상태 카드</a> &nbsp; <a href="#자동-핸드오프">핸드오프</a> &nbsp; <a href="#안전과-제거">안전</a> &nbsp; <a href="./README.md">English</a></p>
15
15
 
16
16
  ## 세션은 이렇게 돌아갑니다
17
17
 
18
- <p align="center"><img src="./docs/assets/readme/demo.webp" width="960" alt="이 Mac에서 실행한 실제 명령 세 개의 터미널 캡처. muse --version은 Muse Code 1.4.11을 출력합니다. litmuse status --card는 lit-plan 경로가 활성 상태이며 계획이 0/2이고 다음 행을 가리킵니다. litmuse models는 호스트 카탈로그의 muse-spark 모델을 보여 줍니다." /></p>
18
+ <p align="center"><img src="./docs/assets/readme/demo.webp" width="960" alt="테스트 프로젝트에서 실행한 실제 명령 세 개의 터미널 카드. muse --version은 Muse Code 1.4.14를 출력합니다. litmuse status --card는 lit-plan 경로가 30초째 활성이고 plans/todo-app.md 계획이 0/2이며 다음 행이 1. Add the shout flag임을 보여 줍니다. litmuse models는 muse-spark-1.3(기본)과 muse-spark-1.2, 각 모델의 effort 단계를 보여 줍니다." /></p>
19
19
 
20
20
  세 줄로 줄이면 계획 세션은 이렇게 흘러갑니다.
21
21
 
@@ -44,6 +44,14 @@ LitMuse는 세션마다 따라갈 순서를 줍니다. 프롬프트를 `lit`으
44
44
 
45
45
  세션을 돌리고 모델을 고르는 것은 여전히 Muse입니다. LitMuse는 일하는 방식을 더하고, 그 방식은 직접 읽을 수 있는 평범한 파일로 남습니다.
46
46
 
47
+ ## Muse 앱에서도
48
+
49
+ Meta의 Muse 제품은 두 가지입니다. 이 README가 다루는 Muse Code는 터미널에서 쓰는 에이전트이고, Muse 앱은 채팅 창과 사이드챗, 노트, 할 일을 갖춘 데스크톱 에이전트입니다. LitFamily는 Muse 앱 안에서도 돌아갑니다. lit 스킬이 똑같이 불려 오고, 답은 터미널에서 보던 점화 표시로 시작합니다.
50
+
51
+ 아래 화면은 Muse 앱에서 새로 연 사이드챗 하나입니다. 터미널에서 쓰는 작은 오픈소스 메모 도구의 이름 후보를 `/lit-brainstorm`에 물었습니다. 답은 `🔥 LIT IGNITED · lit-brainstorm 🔥`로 시작하고, 점수를 매기기 전에 가중치 기준부터 정합니다. 이미 쓰이는 이름(`jot`, `memo`, `mnemo`, `scratch`, `margin`)을 먼저 걸러 낸 뒤 상위 10개 점수표를 보여 줍니다.
52
+
53
+ <p align="center"><img src="./docs/assets/muse-app/lit-brainstorm.webp" width="420" alt="Muse 데스크톱 앱의 사이드챗 하나. 제목은 터미널 메모 도구 이름 추천. 사용자 프롬프트는 /lit-brainstorm 뒤에 작은 오픈소스 터미널 메모 도구의 이름 후보와 점수를 묻는 한국어 요청. 답은 🔥 LIT IGNITED · lit-brainstorm 🔥로 시작해, 후보 32개를 뽑고 이미 쓰이는 이름을 먼저 걸렀다고 말하고, 100점 환산 가중치 기준과 탈락한 jot, memo, mnemo, scratch, margin을 적은 뒤, termemo 87%, jotlet 80%, memlet 80%가 앞선 상위 10개 점수표를 보여 준다." /></p>
54
+
47
55
  ## 설치
48
56
 
49
57
  Node.js 20 이상과, 한 번 로그인해 둔 Muse Code가 필요합니다. 터미널에서 실행하세요.
@@ -109,15 +117,15 @@ Muse의 `/hud`는 켜고 끄는 고정 항목 목록이라서 플러그인 글
109
117
 
110
118
  쉴 때 카드는 활성 경로가 없다고 알립니다.
111
119
 
112
- <p align="center"><img src="./docs/assets/card/idle.webp" width="960" alt="활성 경로가 없는 상태의 상태 카드." /></p>
120
+ <p align="center"><img src="./docs/assets/card/idle.webp" width="960" alt="실행 중인 경로가 없을 때의 litmuse status --card 터미널 카드. 이 워크스페이스에 활성 경로가 없고, plans/todo-app.md 계획은 0/2, 다음 행은 1. Add the shout flag입니다." /></p>
113
121
 
114
122
  계획이 돌 때 카드는 경로와 계획 파일, 다음 행을 보여 줍니다.
115
123
 
116
- <p align="center"><img src="./docs/assets/card/working.webp" width="960" alt="lit-plan 경로가 활성 상태인 카드. 계획 파일의 0/2행과 다음으로 풀 행을 가리킵니다." /></p>
124
+ <p align="center"><img src="./docs/assets/card/working.webp" width="960" alt="lit-plan이 실행 중일 때의 litmuse status --card 터미널 카드. lit-plan이 30초째 활성이고 lit-plan 스레드 하나가 실행 중이며, plans/todo-app.md 계획은 0/2, 다음 행은 1. Add the shout flag입니다." /></p>
117
125
 
118
126
  행에 체크하면 개수와 다음 행 표시가 바뀝니다.
119
127
 
120
- <p align="center"><img src="./docs/assets/card/after.webp" width="960" alt="첫 행을 체크한 뒤의 같은 카드. 1/2이며 두 번째 행을 다음으로 가리킵니다." /></p>
128
+ <p align="center"><img src="./docs/assets/card/after.webp" width="960" alt="1번 행을 체크한 뒤의 같은 카드. plans/todo-app.md 계획이 1/2가 되고 다음 행은 2. Write the tests입니다." /></p>
121
129
 
122
130
  카드는 프로젝트 밖에 있는 `~/.local/state/litmuse/` 아래 훅 기록을 읽습니다.
123
131
 
@@ -127,17 +135,17 @@ Muse의 `/hud`는 켜고 끄는 고정 항목 목록이라서 플러그인 글
127
135
 
128
136
  훅은 기록 한 줄로 답합니다.
129
137
 
130
- <p align="center"><img src="./docs/assets/handoff/saved.webp" width="960" alt="PreCompact 훅의 출력 한 줄. .litmuse/handoff/latest.md를 저장했다고 기록합니다." /></p>
138
+ <p align="center"><img src="./docs/assets/handoff/saved.webp" width="960" alt="muse plugins hook test로 PreCompact 훅을 실행한 터미널 카드. 상태 completed, 종료 코드 0, 시스템 메시지 LitMuse saved .litmuse/handoff/latest.md." /></p>
131
139
 
132
140
  스냅샷에는 실행 계기, 세션, 경로, 열린 행이 있는 계획, 최근 기록 줄이 들어갑니다.
133
141
 
134
- <p align="center"><img src="./docs/assets/handoff/latest.webp" width="960" alt="인계 스냅샷. 실행 계기와 세션, lit-plan 경로, 0개 체크와 2개 열린 행의 계획, 최근 기록 줄을 보여 줍니다." /></p>
142
+ <p align="center"><img src="./docs/assets/handoff/latest.webp" width="960" alt="cat .litmuse/handoff/latest.md 터미널 카드. LitMuse 압축 스냅샷으로 trigger manual, 세션 readme-demo-01, lit-plan 경로, 0개 체크와 2개 열린 행의 plans/todo-app.md 계획, 다음 열린 행, read_file과 write_file의 최근 기록 두 줄을 보여 줍니다." /></p>
135
143
 
136
144
  ## 만들 수 있는 것
137
145
 
138
146
  품질 패리티 라운드에서 Mac에서 직접 렌더한 결과물 4종입니다. 파일을 열어 눈으로 읽고, 어색한 곳은 고쳐 다시 렌더했습니다.
139
147
 
140
- <p align="center"><img src="./docs/assets/parity/lit-adhd-brief.png" width="960" alt="실제 터미널의 lit-adhd 브리프. 무지개 헤드라인, 목표와 작업 시간이 적힌 활성 세션 카드 하나, 도구, 완료된 커밋, 박스형 권장 다음 단계." /></p>
148
+ <p align="center"><img src="./docs/assets/parity/lit-adhd-brief.png" width="960" alt="litmuse rewind로 출력한 lit-adhd 브리프의 터미널 카드. 세션 2개와 대기 0개를 알리는 무지개 헤드라인, todo-app에서 끝난 두 세션의 표, plans/todo-app.md의 1/2 로드맵 게이지, 계획 시작을 권하는 박스형 다음 단계." /></p>
141
149
 
142
150
  `lit-adhd`는 실행 중 세션을 브리핑합니다. 상태 뱃지가 달린 세션 카드와 종료 표, 박스형 권장 다음 단계 — 터미널 색상 그대로, 내부 필드 이름은 새지 않습니다.
143
151
 
@@ -145,7 +153,7 @@ Muse의 `/hud`는 켜고 끄는 고정 항목 목록이라서 플러그인 글
145
153
 
146
154
  `lit-pptx`는 자료로 실제 덱을 엮습니다. 이 덱은 포팅 웨이브별 테스트 스위트 증가를 그립니다.
147
155
 
148
- <p align="center"><img src="./docs/assets/parity/lit-schematic-pid.png" width="960" alt="lit-schematic P&ID. 탱크 TK-101, 상용·예비 도징 펌프, 릴리프 밸브 스필백, 계장 버블, 라인 번호, 심벌 범례, EX-PID-001 타이틀 블록을 갖춘 차아염소산나트륨 도징 스키드." /></p>
156
+ <p align="center"><img src="./docs/assets/parity/lit-schematic-pid.png" width="960" alt="예시 값으로 그린 작업 예제임을 밝힌 lit-schematic 차아염소산나트륨 도징 스키드 P&ID. LT-101·LIA-101이 달린 탱크 TK-101, 차단 밸브 HV-101과 스트레이너 STR-101, 체크 밸브가 달린 상용·예비 펌프 P-101A·P-101B, 8 barg에서 탱크로 되돌리는 PSV-101, 토출 측 PI-101과 FIT-101, 라인 번호, 범례, EX-PID-001 rev B 타이틀 블록." /></p>
149
157
 
150
158
  `lit-schematic`은 QA를 통과한 P&ID를 그립니다. 실제 심벌과 범례, 라인 번호, 타이틀 블록을 갖춘 도징 스키드이며, 모든 라벨이 읽힐 때까지 일곱 번 고쳐 그렸습니다.
151
159
 
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/lib/install.mjs CHANGED
@@ -129,14 +129,21 @@ export async function install({ env = process.env, flags = {}, interactive = fal
129
129
  let states = approvalStates(muse(env, ['plugins', 'inspect', 'litmuse', '--json']).json);
130
130
  const pending = states.filter((s) => s.status !== 'trusted_enabled');
131
131
  if (pending.length) {
132
- print('');
133
- print(`Muse runs plugin hooks and agents only after you approve them (an update re-arms the agent approvals, and the hook approvals whenever hook code changed). ${pending.length} are waiting:`);
134
- for (const s of pending) print(` ${s.id} (${s.what})`);
135
132
  let approve = Boolean(flags.approve);
133
+ const decided = approve;
134
+ if (!decided) {
135
+ print('');
136
+ print(`Muse runs plugin hooks and agents only after you approve them (an update re-arms the agent approvals, and the hook approvals whenever hook code changed). ${pending.length} are waiting:`);
137
+ for (const s of pending) print(` ${s.id} (${s.what})`);
138
+ }
136
139
  if (!approve && interactive && ask) {
137
140
  approve = await ask(`Approve these ${pending.length} LitMuse capabilities now? [y/N] `);
138
141
  }
139
142
  if (approve) {
143
+ if (decided) {
144
+ print('');
145
+ print(`Approving ${pending.length} LitMuse capabilities...`);
146
+ }
140
147
  const failed = [];
141
148
  for (const s of pending) {
142
149
  const r = muse(env, ['plugins', 'approve', s.id, '--json']);
@@ -145,6 +152,21 @@ export async function install({ env = process.env, flags = {}, interactive = fal
145
152
  written.push(`${p.museSettings} (runtime_capabilities for litmuse)`);
146
153
  for (const f of failed) print(` FAIL approve ${f}`);
147
154
  states = approvalStates(muse(env, ['plugins', 'inspect', 'litmuse', '--json']).json);
155
+ const byId = new Map(states.map((s) => [s.id, s]));
156
+ const approved = pending.filter((s) => byId.get(s.id)?.status === 'trusted_enabled');
157
+ const still = states.filter((s) => s.status !== 'trusted_enabled');
158
+ if (approved.length) {
159
+ print('');
160
+ print(`Approved ${approved.length} LitMuse capabilities:`);
161
+ for (const s of approved) print(` ${s.id} (${s.what})`);
162
+ }
163
+ if (still.length) {
164
+ print('');
165
+ print(`${still.length} are still waiting:`);
166
+ for (const s of still) print(` ${s.id} (${s.what})`);
167
+ } else {
168
+ print('No capabilities are still waiting for approval.');
169
+ }
148
170
  } else {
149
171
  print('');
150
172
  print('Approval needed. Run these when you are ready (or rerun install with --approve):');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litmuse",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Installer for LitMuse: skills, hooks and agents that help Meta's Muse Code plan, build, check and hand off work.",
5
5
  "keywords": [
6
6
  "muse",
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "name": "litmuse",
4
4
  "displayName": "LitMuse",
5
- "version": "0.1.7",
5
+ "version": "0.1.9",
6
6
  "description": "LitFamily workflows for Muse Code: bare lit activation, planning, evidence-bound execution, review, research, handoff and Korean prose care.",
7
7
  "compat": {
8
8
  "source": "native",
@@ -16,57 +16,65 @@ sheet.equipmentInfo('P-101B', 'Dosing pump, 0-40 L/h at 6 barg, standby');
16
16
  sheet.notes([
17
17
  'Line numbers read size-fluid-sequence.',
18
18
  'Pumps P-101A/B: duty and standby; one runs.',
19
- 'PSV-101 spills pump discharge back to suction.',
19
+ 'PSV-101, set 8 barg, spills back to suction.',
20
20
  ]);
21
21
 
22
22
  // Feed from unloading into the tank. The off-page box sits clear of the
23
- // level instruments, and the loop tag goes left so it never meets the tank.
24
- sheet.offpage('FROM UNLOADING', 12, 106, 'out', 64);
25
- sheet.pipe([[76, 106], [118, 106]], { cls: 'process', number: '2-NAOCL-100', at: 0.548 });
26
- sheet.tank('TK-101', 140, 130);
27
- sheet.vent(140, 76);
28
- sheet.instrument('LT-101', 78, 140, { tag: 'below' });
29
- sheet.signal([[91, 140], [118, 140]], 'electric');
30
- sheet.instrument('LIA-101', 78, 80, { dcs: true, tag: 'left' });
23
+ // level instruments; each bubble carries its own tag, so none is printed beside it.
24
+ sheet.offpage('FROM UNLOADING', 12, 110, 'out', 64);
25
+ sheet.pipe([[76, 110], [121, 110]], { cls: 'process', number: '2-NAOCL-100', at: 0.52 });
26
+ sheet.tank('TK-101', 143, 126, 'right'); // tag beside the shell, clear of the outlet and the PSV
27
+ sheet.vent(143, 80); // leg starts on the roof nozzle
28
+ sheet.instrument('LT-101', 78, 140);
29
+ sheet.signal([[91, 140], [121, 140]], 'electric');
30
+ sheet.instrument('LIA-101', 78, 80, { dcs: true });
31
31
  sheet.signal([[78, 93], [78, 127]], 'electric');
32
32
 
33
- sheet.pipe([[140, 156], [140, 182]], { cls: 'process' });
33
+ sheet.pipe([[143, 152], [143, 182]], { cls: 'process' });
34
34
  sheet.pipe([[40, 182], [250, 182]], { cls: 'process', number: '2-NAOCL-101', at: 0.24 });
35
35
  sheet.drain(60, 182);
36
36
  sheet.valve('gate', 'hand', 'FV-101', 120, 182, 'FC');
37
- sheet.strainer('STR-101', 150, 182);
38
- sheet.arrow(134, 182, true);
37
+ sheet.strainer('STR-101', 157, 182);
38
+ sheet.arrow(96, 182, true);
39
39
 
40
- // Duty and standby pump branches. Tags sit beside the circles: a vertical drop
41
- // through a centered tag would strike it, and the checks below need the room.
40
+ // Duty and standby pump branches, far enough apart that the standby tag clears
41
+ // the duty pump's circle. Pump tags sit beside the circles: a vertical
42
+ // drop through a centered tag would strike it. The check valves on the vertical
43
+ // discharge drops take their tags beside the pipe, to the right, clear of the drop.
42
44
  sheet.pipe([[188, 182], [188, 190]], { cls: 'process' });
43
45
  sheet.pump('P-101A', 188, 200, 'left');
44
46
  sheet.pipe([[188, 210], [188, 232]], { cls: 'process' });
45
- sheet.valve('check', 'auto', 'XV-101A', 188, 224, 'FC', 'v');
46
- sheet.pipe([[234, 182], [234, 190]], { cls: 'process' });
47
- sheet.pump('P-101B', 234, 200, 'left');
48
- sheet.pipe([[234, 210], [234, 232]], { cls: 'process' });
49
- sheet.valve('check', 'auto', 'XV-101B', 234, 224, 'FC', 'v');
47
+ sheet.valve('check', 'auto', 'XV-101A', 188, 219, 'FC', 'v', 'right');
48
+ sheet.pipe([[240, 182], [240, 190]], { cls: 'process' });
49
+ sheet.pump('P-101B', 240, 200, 'left');
50
+ sheet.pipe([[240, 210], [240, 232]], { cls: 'process' });
51
+ sheet.valve('check', 'auto', 'XV-101B', 240, 219, 'FC', 'v', 'right');
50
52
 
51
53
  // Discharge header to the injection riser. The globe sits west of the duty
52
- // branch with its fail tag clear of the drop; the line number rides east of it.
53
- sheet.pipe([[120, 232], [246, 232]], { cls: 'process', number: '2-NAOCL-102', at: 0.72 });
54
+ // branch with its fail tag clear of the drop; the line number rides between the
55
+ // two pump drops, under the duty check valve's tag. The riser runs east of the
56
+ // standby tag, and its flow arrow points up.
57
+ sheet.pipe([[128, 232], [284, 232]], { cls: 'process', number: '2-NAOCL-102', at: 0.532 });
54
58
  sheet.valve('globe', 'pneumatic', 'FV-102', 140, 232, 'FO');
55
- sheet.pipe([[246, 232], [246, 100]], { cls: 'process' });
56
- sheet.orifice('FE-101', 246, 140);
57
- sheet.instrument('FT-101', 266, 160);
58
- sheet.signal([[253, 160], [250, 160], [250, 150]], 'electric');
59
- sheet.instrument('PI-101', 270, 214);
60
- sheet.pipe([[257, 214], [246, 214]], { cls: 'utility' });
59
+ sheet.pipe([[284, 232], [284, 100]], { cls: 'process' });
60
+ sheet.arrow(284, 186, true, 'up');
61
+ sheet.orifice('FE-101', 284, 140);
62
+ sheet.instrument('FT-101', 266, 165);
63
+ sheet.signal([[266, 151.8], [266, 140], [281, 140]], 'electric');
64
+ sheet.instrument('PI-101', 268, 200);
65
+ sheet.pipe([[281.2, 200], [284, 200]], { cls: 'utility' });
61
66
  sheet.instrument('FIC-101', 198, 96, { dcs: true });
62
- sheet.signal([[198, 109], [198, 144]], 'electric');
63
- sheet.offpage('TO INJECTION POINT', 246, 100, 'out', 60);
67
+ sheet.signal([[198, 109], [198, 165], [252.8, 165]], 'electric'); // FIC reads FT-101
68
+ sheet.offpage('TO INJECTOR', 284, 100, 'out');
64
69
 
65
- // PSV spillback to the suction header, west of the duty branch so the tags,
66
- // the tank outlet, and the line number each keep their own lane.
67
- sheet.pipe([[180, 232], [180, 170]], { cls: 'process' });
68
- sheet.psv('PSV-101', 180, 160, 'SET 8 barg');
69
- sheet.pipe([[186, 156], [186, 150], [206, 150], [226, 150], [226, 184]], { cls: 'process', number: '1-NAOCL-103', at: 0.5 });
70
+ // PSV spillback, between the two headers west of the globe valve: the angle body
71
+ // takes its inlet from the discharge header below and spills right, then up into
72
+ // the suction header, so no line crosses a pump. The tag sits opposite the outlet;
73
+ // the set pressure rides in note 3.
74
+ sheet.pipe([[128, 232], [128, 224]], { cls: 'process' });
75
+ sheet.psv('PSV-101', 128, 214, null, { outlet: 'right' });
76
+ sheet.pipe([[138, 214], [138, 182]], { cls: 'process' });
77
+ sheet.arrow(138, 196, true, 'up');
70
78
 
71
79
  sheet.legend();
72
80
  writeFileSync(join(out, 'drawing.svg'), sheet.toSVG());
@@ -8,6 +8,21 @@ Import the kit root and construct sheets, figures, and views from it.
8
8
  `pipe(from, to, opts)`, `signal(from, to, kind)`, `inline(line, item)`, `equipmentInfo(tag,
9
9
  info)`.
10
10
 
11
+ P&ID sheet methods that place parts (coordinates in sheet mm):
12
+ - `pipe(points, { cls, number, at })` routes orthogonally; `number` rides above a horizontal run
13
+ or, rotated, 8 mm right of a vertical one, `at` along the longest segment.
14
+ - `arrow(x, y, clear = true, dir = 'right')` draws an open flow chevron with its tip at
15
+ (x, y); `dir` is `right`, `left`, `up` or `down` and must match the flow on that run.
16
+ - `valve(body, actuator, id, x, y, fail = 'FC', orient = 'h', tagSide = 'left')`: with
17
+ `orient: 'v'` the actuator points right and the tag goes beside the pipe, left by default or
18
+ right of the actuator with `tagSide: 'right'`.
19
+ - `strainer(id, x, y, orient = 'h', tagSide = 'left')`: same rule on a vertical pipe.
20
+ - `psv(tag, x, y, set, { outlet = 'right', inlet })`: angle body, spring opposite the inlet,
21
+ tag opposite the outlet; inlet and outlet pipes connect 10 from the center.
22
+ - `instrument(tag, x, y, { dcs })`: the bubble holds the tag (letters over number); no side tag.
23
+ - `pump(tag, x, y, side)`, `tank(tag, x, y, side)` (side `below` or `right`; its roof nozzle top is `y - 46`), `orifice`, `offpage(label, x, y, dir, w)`, `drain`, `vent`,
24
+ `signal(points, kind)`, `hop`, `callout`, `notes`, `legend()`, `toSVG()`.
25
+
11
26
  `createFigure(opts)` returns a journal figure with column width (`85`, `120`, `178`).
12
27
  `panel.canvas()` opens a 2D panel; `panel.iso(view)` opens a 3D panel with the isometric
13
28
  camera. `fig.key(roles, at)` draws the stream key. `stream({ role })` colours a stream from
@@ -10,8 +10,12 @@ process piping. A line that cannot route throws with the reason; move a symbol o
10
10
  waypoints, and route around symbols.
11
11
 
12
12
  Inline items (valves, filters, flow elements, reducers) sit on the line through `sheet.inline`.
13
- Branches leave through `line.tap` at straight runs. Asked-for flow arrows slide along open
14
- pipe to clear spans; a span shorter than 30 mm takes a warn flag.
13
+ On a vertical pipe, draw valves with `orient: 'v'` and strainers with `'v'`: their tag moves
14
+ beside the pipe (left by default, `'right'` to pass the actuator side) instead of sitting on it.
15
+ A vertical line number reads bottom-to-top, 8 mm right of its line, with the same air as a
16
+ horizontal number. Branches leave through `line.tap` at straight runs. Asked-for flow arrows
17
+ slide along open pipe to clear spans and point the way the flow runs (`right`, `left`, `up`,
18
+ `down`); a span shorter than 30 mm takes a warn flag.
15
19
 
16
20
  Line hops: arcs of radius 2 mm or less mark crossings; larger arcs read as bends and fail the
17
21
  bend check. Leaders keep 1.2 mm at print size between leaders and between leaders and dots,
@@ -5,8 +5,9 @@ P pressure, T temperature), succeeding letters the function (I indicate, C contr
5
5
  V valve). Tag order follows the letter table; the QA gate flags letter-order breaks.
6
6
 
7
7
  Bubbles carry function plus location: a plain circle for field instruments, a horizontal bar
8
- for panel-board mounting, a dashed outline for computer-resident functions. Every bubble shows
9
- its loop tag beside it.
8
+ for panel-board mounting, a dashed outline for computer-resident functions. The bubble itself
9
+ is the tag: letters on the upper line, loop number on the lower. `sheet.instrument` prints no
10
+ second copy beside it; its old `tag` placement option is accepted and ignored.
10
11
 
11
12
  Line types: process lines heavy (0.7), utility lines medium (0.5), instrument connections and
12
13
  signal lines light (0.35), with leader dots at 0.18. Process, utility, and signal lines stay
@@ -22,6 +23,13 @@ form, eccentric reducer, flange pair, venturi and magnetic/Coriolis/vortex flow
22
23
  figure-8 spectacle blind, line-size breaks, strainers, traps, rupture disks, PSVs with
23
24
  discharge destinations.
24
25
 
26
+ The PSV is an angle body: an inlet triangle and an outlet triangle meet at the body center, the
27
+ spring rises from the center on the side opposite the inlet (never over the inlet stem), and the
28
+ tag plus set pressure sit on the side opposite the outlet. `sheet.psv(tag, x, y, set, { outlet,
29
+ inlet })` takes `outlet` right (default), left, up or down; `inlet` defaults to down for a
30
+ sideways outlet and left for a vertical one, and must sit at a right angle to the outlet. Pipes
31
+ connect 10 mm from the center along each direction.
32
+
25
33
  Line numbers parse through a small grammar (size, fluid code, class, sequence) with a dedicated
26
34
  QA check. Signal marks follow ISA-5.1 Table 5.3.2 for pneumatic, electric, capillary, and
27
35
  software links.
@@ -2,13 +2,20 @@
2
2
 
3
3
  QA codes (`qa.mjs --codes` lists all): TITLEBLOCK.FIELDS, LEGEND.COVERAGE, TAG.ORDER,
4
4
  LINE.NUMBER, LINE.WEIGHT, LINE.ARROW_IN_SYMBOL, LINE.BEND_ON_LINE, LEADER.SPACING,
5
- LABEL.CLEARANCE, EXPORT.DPI, EXPORT.PDF_SIZE.
5
+ LABEL.CLEARANCE, SYMBOL.CONNECTED, LINE.THROUGH_SYMBOL, EXPORT.DPI, EXPORT.PDF_SIZE.
6
6
 
7
7
  Fixes: TITLEBLOCK gains the missing fields in the build. LEGEND rebuilds from the drawn set
8
8
  through `createLegendSheet`. TAG reorders letters per `references/pid-symbols.md`.
9
9
  LINE.NUMBER parses through the line grammar. LINE.WEIGHT remaps roles to 0.7/0.5/0.35/0.18.
10
10
  Arrows slide to open pipe. Hop arcs shrink to 2 mm or less. Leaders respread per
11
- `references/pid-drafting.md`. Labels move clear of lines and art.
11
+ `references/pid-drafting.md`. Labels move clear of lines and art. LABEL.CLEARANCE also
12
+ flattens arcs and curves (vent hoods, actuator domes, hop arcs) and checks every circle: a label
13
+ across a bubble or pump outline fails (`sits on a circle`), while a bubble's own letters, wholly
14
+ inside it, pass. Against lines and outlines a label needs 0.3 mm of air: a line number touching a
15
+ vessel wall or a signal line fails. SYMBOL.CONNECTED fails a vent whose leg meets no nozzle, vessel or
16
+ pipe; start the vent on the nozzle's top edge (for `tank(tag, x, y)` that is `vent(x, y - 46)`).
17
+ LINE.THROUGH_SYMBOL fails a line that cuts through a pump casing or an instrument bubble; a line may
18
+ end on the outline, and filled dots (junctions, globe seats) belong to the line.
12
19
 
13
20
  Render loop: build, QA, render PNG, read the PNG, sweep dense sheets in a 3x2 tile, fix in
14
21
  the build, rebuild. Rebuilds stay byte-identical for unchanged inputs.
@@ -21,7 +21,7 @@ function kv(s) {
21
21
  export const CODES = [
22
22
  'TITLEBLOCK.FIELDS', 'LEGEND.COVERAGE', 'TAG.ORDER', 'LINE.NUMBER',
23
23
  'LINE.WEIGHT', 'LINE.ARROW_IN_SYMBOL', 'LINE.BEND_ON_LINE', 'LEADER.SPACING',
24
- 'LABEL.CLEARANCE',
24
+ 'LABEL.CLEARANCE', 'SYMBOL.CONNECTED', 'LINE.THROUGH_SYMBOL',
25
25
  ];
26
26
 
27
27
  // ---- LABEL.CLEARANCE geometry: no label sits on another label or a line ----
@@ -88,13 +88,72 @@ function shrink(box) {
88
88
  return { x0: box.x0 + INSET, x1: box.x1 - INSET, y0: box.y0 + INSET, y1: box.y1 - INSET, label: box.label };
89
89
  }
90
90
 
91
+ const AIR = 0.3; // a label keeps this much space from lines and outlines
92
+
93
+ // Undo the label-to-label inset and add AIR all round.
94
+ function air(box) {
95
+ const d = INSET + AIR;
96
+ return { x0: box.x0 - d, x1: box.x1 + d, y0: box.y0 - d, y1: box.y1 + d, label: box.label };
97
+ }
98
+
99
+ function pointSegDist(px, py, [x1, y1, x2, y2]) {
100
+ const dx = x2 - x1;
101
+ const dy = y2 - y1;
102
+ const len = dx * dx + dy * dy;
103
+ const t = len ? Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len)) : 0;
104
+ return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
105
+ }
106
+
91
107
  function overlaps(a, b) {
92
108
  return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
93
109
  }
94
110
 
95
- // Line, polyline and path segments as centerlines. Curves contribute only
96
- // their endpoints (rects and circles are checked by their edges, not needed
97
- // here): clearance is about labels struck by straight runs.
111
+ // Circular arc (the kit draws rx = ry, no rotation) as short chords, from the
112
+ // SVG endpoint parameterisation (SVG 1.1 F.6.5).
113
+ function arcChords(x1, y1, r, large, sweep, x2, y2) {
114
+ const hx = (x1 - x2) / 2;
115
+ const hy = (y1 - y2) / 2;
116
+ const d2 = hx * hx + hy * hy;
117
+ if (!d2 || !r) return [[x1, y1, x2, y2]];
118
+ const rr = Math.max(Math.abs(r), Math.sqrt(d2));
119
+ const k = (large !== sweep ? 1 : -1) * Math.sqrt(Math.max(0, (rr * rr - d2) / d2));
120
+ const cx = k * hy + (x1 + x2) / 2;
121
+ const cy = -k * hx + (y1 + y2) / 2;
122
+ const a1 = Math.atan2(y1 - cy, x1 - cx);
123
+ let da = Math.atan2(y2 - cy, x2 - cx) - a1;
124
+ if (sweep && da < 0) da += 2 * Math.PI;
125
+ if (!sweep && da > 0) da -= 2 * Math.PI;
126
+ const out = [];
127
+ let [px, py] = [x1, y1];
128
+ for (let s = 1; s <= 12; s++) {
129
+ const a = a1 + (da * s) / 12;
130
+ const [nx, ny] = s === 12 ? [x2, y2] : [cx + rr * Math.cos(a), cy + rr * Math.sin(a)];
131
+ out.push([px, py, nx, ny]);
132
+ [px, py] = [nx, ny];
133
+ }
134
+ return out;
135
+ }
136
+
137
+ // Bezier (quadratic or cubic) as short chords.
138
+ function bezierChords(pts) {
139
+ const out = [];
140
+ const at = (t) => {
141
+ let p = pts;
142
+ while (p.length > 1) p = p.slice(1).map((q, i) => [p[i][0] + (q[0] - p[i][0]) * t, p[i][1] + (q[1] - p[i][1]) * t]);
143
+ return p[0];
144
+ };
145
+ let prev = pts[0];
146
+ for (let s = 1; s <= 12; s++) {
147
+ const next = at(s / 12);
148
+ out.push([prev[0], prev[1], next[0], next[1]]);
149
+ prev = next;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ // Line, polyline and path segments as centerlines; arcs and Bezier curves are
155
+ // flattened into short chords so a label on a vent hood or an actuator dome is
156
+ // caught too. Rects are checked by their edges; circles by circleHitsBox.
98
157
  function lineSegments(svg) {
99
158
  const segs = [];
100
159
  const push = (x1, y1, x2, y2) => {
@@ -138,11 +197,17 @@ function lineSegments(svg) {
138
197
  push(cx, cy, sx, sy);
139
198
  [cx, cy] = [sx, sy];
140
199
  } else if (/[ACSTQ]/i.test(cmd)) {
141
- // Curves and arcs: only the endpoint matters for clearance.
142
200
  const count = /A/i.test(cmd) ? 7 : /C/i.test(cmd) ? 6 : 4;
143
201
  const nums = [];
144
202
  for (let k = 0; k < count && i < toks.length; k++) nums.push(Number(toks[i++]));
145
- if (nums.length >= 2) [cx, cy] = nums.slice(-2);
203
+ if (nums.length < count) break;
204
+ let chords = [];
205
+ if (cmd === 'A') chords = arcChords(cx, cy, nums[0], nums[3] !== 0, nums[4] !== 0, nums[5], nums[6]);
206
+ else if (cmd === 'C') chords = bezierChords([[cx, cy], [nums[0], nums[1]], [nums[2], nums[3]], [nums[4], nums[5]]]);
207
+ else if (cmd === 'Q') chords = bezierChords([[cx, cy], [nums[0], nums[1]], [nums[2], nums[3]]]);
208
+ else chords = [[cx, cy, ...nums.slice(-2)]]; // S and T: the kit never emits them; keep the chord
209
+ for (const [a, b, c, d] of chords) push(a, b, c, d);
210
+ [cx, cy] = nums.slice(-2);
146
211
  }
147
212
  }
148
213
  }
@@ -156,6 +221,29 @@ function lineSegments(svg) {
156
221
  return segs;
157
222
  }
158
223
 
224
+ // Every <circle>: bubbles, pump casings, motor and seat dots.
225
+ function circles(svg) {
226
+ const out = [];
227
+ for (const m of svg.matchAll(/<circle\b([^>]*)\/>/g)) {
228
+ const get = (name) => Number((m[1].match(new RegExp(`\\b${name}="([\\d.]+)"`)) ?? [])[1]);
229
+ const fill = (m[1].match(/\bfill="([^"]+)"/) ?? [])[1] ?? 'none';
230
+ const c = { cx: get('cx'), cy: get('cy'), r: get('r'), filled: fill !== 'none' && fill !== '#fff' && fill !== '#ffffff' };
231
+ if (Number.isFinite(c.cx + c.cy + c.r) && c.r > 0) out.push(c);
232
+ }
233
+ return out;
234
+ }
235
+
236
+ // A label strikes a circle when the circle's disc reaches into the label box
237
+ // but the box is not wholly inside the circle: a bubble's own letters sit
238
+ // inside and pass; a tag across the outline, or a dot inside a word, fails.
239
+ function circleHitsBox({ cx, cy, r }, box) {
240
+ const nx = Math.min(Math.max(cx, box.x0), box.x1);
241
+ const ny = Math.min(Math.max(cy, box.y0), box.y1);
242
+ if (Math.hypot(nx - cx, ny - cy) >= r) return false;
243
+ const far = Math.hypot(Math.max(Math.abs(box.x0 - cx), Math.abs(box.x1 - cx)), Math.max(Math.abs(box.y0 - cy), Math.abs(box.y1 - cy)));
244
+ return far > r;
245
+ }
246
+
159
247
  function segHitsBox(x1, y1, x2, y2, box) {
160
248
  if (x1 >= box.x0 && x1 <= box.x1 && y1 >= box.y0 && y1 <= box.y1) return true;
161
249
  if (x2 >= box.x0 && x2 <= box.x1 && y2 >= box.y0 && y2 <= box.y1) return true;
@@ -230,11 +318,31 @@ export function runQA(svg) {
230
318
  }
231
319
  }
232
320
  }
321
+ const runs = lineSegments(svg);
322
+ // A vent is a pipe end: its leg must start on a nozzle, a vessel or a pipe.
323
+ for (const c of comments(svg, 'symbol')) {
324
+ const v = kv(c);
325
+ if (v.kind !== 'vent' || !v.at) continue;
326
+ const [vx, vy] = v.at.split(',').map(Number);
327
+ const own = ([x1, y1, x2, y2]) => x1 === vx && y1 === vy && x2 === vx && y2 < vy;
328
+ const touches = runs.some((s) => !own(s) && pointSegDist(vx, vy, s) <= 0.3);
329
+ if (!touches) fails.push({ code: 'SYMBOL.CONNECTED', msg: `vent at ${vx},${vy} floats: its leg meets no nozzle, vessel or pipe` });
330
+ }
331
+ // A line may end on a pump casing or an instrument bubble but never cut
332
+ // through one. Filled dots (junctions, globe seats) are part of the line.
333
+ for (const c of circles(svg).filter((c) => c.r >= 2 && !c.filled)) {
334
+ const outside = (x, y) => Math.hypot(x - c.cx, y - c.cy) > c.r + 0.5;
335
+ const hit = runs.find((s) => outside(s[0], s[1]) && outside(s[2], s[3]) && pointSegDist(c.cx, c.cy, s) < c.r - 0.5);
336
+ if (hit) fails.push({ code: 'LINE.THROUGH_SYMBOL', msg: `a line from ${hit[0]},${hit[1]} to ${hit[2]},${hit[3]} cuts through the circle at ${c.cx},${c.cy}` });
337
+ }
233
338
  if (!fails.some((f) => f.code === 'LABEL.CLEARANCE')) {
234
- const runs = lineSegments(svg);
235
- for (const box of labels) {
339
+ const rings = circles(svg);
340
+ // Against lines and circles a label needs air: touching a wall or a signal
341
+ // line reads as a collision, so the box grows instead of shrinking here.
342
+ for (const box of labels.map(air)) {
236
343
  const hit = runs.find(([x1, y1, x2, y2]) => segHitsBox(x1, y1, x2, y2, box));
237
344
  if (hit) fails.push({ code: 'LABEL.CLEARANCE', msg: `“${box.label}” sits on a line` });
345
+ else if (rings.some((c) => circleHitsBox(c, box))) fails.push({ code: 'LABEL.CLEARANCE', msg: `“${box.label}” sits on a circle` });
238
346
  }
239
347
  }
240
348
  return { pass: fails.length === 0, fails };
@@ -97,8 +97,8 @@ export function createSheet(opts = {}) {
97
97
  const rows = 'ABCDEF';
98
98
  for (let i = 0; i < 6; i += 1) {
99
99
  const y = FRAME + ((SHEET_H - FRAME * 2) * (i + 0.5)) / 6;
100
- out.push(text(FRAME - 2, y + 2, rows[i], { size: 6, anchor: 'middle' }));
101
- out.push(text(SHEET_W - FRAME + 2, y + 2, rows[i], { size: 6, anchor: 'middle' }));
100
+ out.push(text(FRAME - 2.6, y + 2, rows[i], { size: 6, anchor: 'middle' }));
101
+ out.push(text(SHEET_W - FRAME + 2.6, y + 2, rows[i], { size: 6, anchor: 'middle' }));
102
102
  }
103
103
  out.push(el('line', { x1: FRAME, y1: BAND_BOTTOM, x2: SHEET_W - FRAME, y2: BAND_BOTTOM, stroke: INK, 'stroke-width': WEIGHTS.utility }));
104
104
  out.push(el('line', { x1: FRAME, y1: FIELD_TOP, x2: SHEET_W - FRAME, y2: FIELD_TOP, stroke: INK, 'stroke-width': WEIGHTS.utility }));
@@ -138,8 +138,8 @@ export function createSheet(opts = {}) {
138
138
  const thumb = (kind, x, y) => {
139
139
  const s = [];
140
140
  if (kind === 'tank') {
141
- s.push(el('rect', { x: x - 6, y: y - 6, width: 12, height: 12, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
142
- s.push(el('path', { d: `M ${x - 6} ${y - 6} L ${x + 6} ${y - 6} L ${x} ${y - 10} Z`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
141
+ s.push(el('rect', { x: x - 5, y: y - 4, width: 10, height: 9, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
142
+ s.push(el('path', { d: `M ${x - 5} ${y - 4} L ${x + 5} ${y - 4} L ${x} ${y - 6} Z`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
143
143
  } else if (kind === 'pump') {
144
144
  s.push(el('circle', { cx: x, cy: y, r: 6, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
145
145
  s.push(el('path', { d: `M ${x - 3} ${y - 3} L ${x - 3} ${y + 3} L ${x + 3} ${y} Z`, fill: INK }));
@@ -152,7 +152,8 @@ export function createSheet(opts = {}) {
152
152
  } else if (kind.startsWith('filter')) {
153
153
  s.push(el('path', { d: `M ${x} ${y - 6} L ${x + 6} ${y} L ${x} ${y + 6} L ${x - 6} ${y} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
154
154
  } else if (kind === 'psv') {
155
- s.push(el('path', { d: `M ${x - 4} ${y + 6} L ${x - 4} ${y - 2} L ${x - 1} ${y} L ${x + 2} ${y - 4} L ${x + 5} ${y} L ${x + 5} ${y - 6} L ${x + 9} ${y - 2}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
155
+ s.push(el('path', { d: `M ${x} ${y + 1} L ${x - 2.5} ${y + 6} L ${x + 2.5} ${y + 6} Z M ${x} ${y + 1} L ${x + 5} ${y - 1.5} L ${x + 5} ${y + 3.5} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
156
+ s.push(el('path', { d: `M ${x} ${y + 1} L ${x} ${y - 1} L ${x + 2} ${y - 2} L ${x - 2} ${y - 3.5} L ${x + 2} ${y - 5} L ${x} ${y - 6}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
156
157
  } else if (kind === 'orifice') {
157
158
  s.push(el('line', { x1: x - 4, y1: y - 4, x2: x + 4, y2: y - 4, stroke: INK, 'stroke-width': WEIGHTS.process }));
158
159
  s.push(el('line', { x1: x - 2, y1: y - 5, x2: x - 2, y2: y + 5, stroke: INK, 'stroke-width': WEIGHTS.process }));
@@ -179,16 +180,18 @@ export function createSheet(opts = {}) {
179
180
  out.push(text(LEGEND_X, FIELD_TOP + 8, 'LEGEND', { size: 8 }));
180
181
  let y = FIELD_TOP + 22;
181
182
  // Two columns of 64: the widest thumbnail (relief zigzag, off-page hexagon)
182
- // ends 12 past its origin, so labels start at +15 and the longest name still
183
- // clears the next column's thumbnails.
183
+ // ends 12 past its origin; thumbnails sit 6 in from the panel rule so a pump
184
+ // circle clears it, labels start at +18, and the longest name still
185
+ // clears the next column's thumbnails. Rows sit 13 apart so the tallest
186
+ // thumbnails (pump circle, tank roof) never touch the row above or below.
184
187
  for (const [index, kind] of [...kinds].sort().entries()) {
185
188
  const col = index < Math.ceil([...kinds].length / 2) ? 0 : 1;
186
- const yy = y + (index % Math.ceil([...kinds].length / 2)) * 11;
189
+ const yy = y + (index % Math.ceil([...kinds].length / 2)) * 13;
187
190
  const xx = LEGEND_X + col * 64;
188
- out.push(thumb(kind, xx + 3, yy - 2));
189
- out.push(text(xx + 15, yy, kindName(kind), { size: 6 }));
191
+ out.push(thumb(kind, xx + 6, yy - 2));
192
+ out.push(text(xx + 18, yy, kindName(kind), { size: 6 }));
190
193
  }
191
- y += Math.ceil([...kinds].length / 2) * 11 + 6;
194
+ y += Math.ceil([...kinds].length / 2) * 13 + 6;
192
195
  const lineSample = (yy, weight, dash, name) => {
193
196
  out.push(el('line', { x1: LEGEND_X, y1: yy - 2, x2: LEGEND_X + 14, y2: yy - 2, stroke: INK, 'stroke-width': weight, ...(dash ? { 'stroke-dasharray': dash } : {}) }));
194
197
  out.push(text(LEGEND_X + 18, yy, name, { size: 6 }));
@@ -223,8 +226,10 @@ export function createSheet(opts = {}) {
223
226
  const x = best.x1 + (best.x2 - best.x1) * at;
224
227
  const y = best.y1 + (best.y2 - best.y1) * at;
225
228
  const vertical = best.x1 === best.x2;
229
+ // Rotated -90, the glyph tops face the pipe: a baseline 8 out leaves about
230
+ // 3.7 mm of air, the same as a horizontal number riding 4 above its line.
226
231
  return vertical
227
- ? el('text', { x: x + 5, y, 'font-size': 6, 'font-family': 'Arial, sans-serif', fill: INK, transform: `rotate(-90 ${x + 5} ${y})`, 'data-clear': 'true' }, number)
232
+ ? el('text', { x: x + 8, y, 'font-size': 6, 'font-family': 'Arial, sans-serif', fill: INK, transform: `rotate(-90 ${x + 8} ${y})`, 'data-clear': 'true' }, number)
228
233
  : text(x, y - 4, number, { size: 6, anchor: 'middle' });
229
234
  };
230
235
 
@@ -238,14 +243,17 @@ export function createSheet(opts = {}) {
238
243
  shapes.push(text(x, y + 14, label ?? id, { size: 7 }));
239
244
  return id;
240
245
  },
241
- tank(tag, x, y) {
246
+ tank(tag, x, y, side = 'below') {
242
247
  kinds.add('tank');
243
248
  marks.push(comment(`symbol kind=tank id=${tag}`));
244
249
  shapes.push(el('path', { d: `M ${x - 22} ${y - 26} L ${x + 22} ${y - 26} L ${x} ${y - 40} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process }));
245
250
  shapes.push(el('rect', { x: x - 22, y: y - 26, width: 44, height: 52, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process }));
246
251
  shapes.push(el('rect', { x: x - 3, y: y - 46, width: 6, height: 6, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
247
252
  shapes.push(el('rect', { x: x - 3, y: y + 26, width: 6, height: 6, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.utility }));
248
- shapes.push(text(x + 10, y + 40, tag, { size: 7 }));
253
+ // 'right' puts the tag beside the shell at mid-height, clear of the bottom
254
+ // nozzle and of anything that hangs below the tank.
255
+ if (side === 'right') shapes.push(text(x + 26, y + 2, tag, { size: 7 }));
256
+ else shapes.push(text(x + 10, y + 40, tag, { size: 7 }));
249
257
  return tag;
250
258
  },
251
259
  pump(tag, x, y, side = 'below') {
@@ -262,21 +270,57 @@ export function createSheet(opts = {}) {
262
270
  else shapes.push(text(x, y + 26, tag, { size: 7, anchor: 'middle' }));
263
271
  return tag;
264
272
  },
265
- strainer(id, x, y) {
273
+ strainer(id, x, y, orient = 'h', tagSide = 'left') {
266
274
  kinds.add('filter.strainer');
267
275
  marks.push(comment(`symbol kind=filter.strainer id=${id}`));
268
276
  shapes.push(el('path', { d: `M ${x} ${y - 7} L ${x + 7} ${y} L ${x} ${y + 7} L ${x - 7} ${y} Z`, fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process }));
269
- shapes.push(text(x, y + 18, id, { size: 7, anchor: 'middle' }));
277
+ // On a vertical pipe a tag below the diamond would sit on the pipe itself.
278
+ if (orient === 'v') shapes.push(text(tagSide === 'right' ? x + 10 : x - 10, y + 2.5, id, { size: 7, anchor: tagSide === 'right' ? 'start' : 'end' }));
279
+ else shapes.push(text(x, y + 18, id, { size: 7, anchor: 'middle' }));
270
280
  return id;
271
281
  },
272
- psv(tag, x, y, set) {
282
+ // Angle body: an inlet triangle and an outlet triangle meet at (x, y), each
283
+ // with a 3-unit stem, so pipes connect 10 from the center: inlet below
284
+ // (x, y + 10) and outlet right (x + 10, y) by default. The spring rises
285
+ // opposite the inlet; the tag and set pressure sit opposite the outlet.
286
+ psv(tag, x, y, set, opts2 = {}) {
287
+ const DIRS = { right: [1, 0], left: [-1, 0], up: [0, -1], down: [0, 1] };
288
+ const outlet = opts2.outlet ?? 'right';
289
+ const inlet = opts2.inlet ?? (outlet === 'left' || outlet === 'right' ? 'down' : 'left');
290
+ const o = DIRS[outlet];
291
+ const n = DIRS[inlet];
292
+ if (!o || !n || o[0] * n[0] + o[1] * n[1] !== 0) {
293
+ throw new Error(`psv ${tag}: inlet ${inlet} and outlet ${outlet} must be at right angles`);
294
+ }
273
295
  kinds.add('psv');
274
- marks.push(comment(`symbol kind=psv id=${tag}`));
275
- shapes.push(el('line', { x1: x, y1: y + 10, x2: x, y2: y - 4, stroke: INK, 'stroke-width': WEIGHTS.process }));
276
- shapes.push(el('path', { d: `M ${x} ${y - 4} L ${x + 3} ${y - 1} L ${x - 3} ${y + 2} L ${x + 3} ${y + 5} L ${x - 3} ${y + 8}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
277
- shapes.push(el('path', { d: `M ${x} ${y - 4} L ${x + 12} ${y - 4} L ${x + 12} ${y + 2} L ${x} ${y + 2}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.process }));
278
- shapes.push(text(x + 14, y - 2, tag, { size: 6 }));
279
- if (set) shapes.push(text(x + 14, y + 6, set, { size: 6 }));
296
+ marks.push(comment(`symbol kind=psv id=${tag} inlet=${inlet} outlet=${outlet}`));
297
+ const at = (v, d, w = [0, 0], e = 0) => [x + v[0] * d + w[0] * e, y + v[1] * d + w[1] * e].map((c) => Math.round(c * 100) / 100 + 0);
298
+ const P = (pt) => `${pt[0]} ${pt[1]}`;
299
+ const sp = [-n[0], -n[1]];
300
+ shapes.push(el('path', {
301
+ d: `M ${x} ${y} L ${P(at(n, 7, o, 3.5))} L ${P(at(n, 7, o, -3.5))} Z M ${x} ${y} L ${P(at(o, 7, n, 3.5))} L ${P(at(o, 7, n, -3.5))} Z`,
302
+ fill: '#fff', stroke: INK, 'stroke-width': WEIGHTS.process,
303
+ }));
304
+ for (const v of [n, o]) {
305
+ const [x1, y1] = at(v, 7);
306
+ const [x2, y2] = at(v, 10);
307
+ shapes.push(el('line', { x1, y1, x2, y2, stroke: INK, 'stroke-width': WEIGHTS.process }));
308
+ }
309
+ const zig = [at(sp, 3), at(sp, 4.5, o, 2.5), at(sp, 6.5, o, -2.5), at(sp, 8.5, o, 2.5), at(sp, 10.5, o, -2.5), at(sp, 12)];
310
+ shapes.push(el('line', { x1: x, y1: y, x2: zig[0][0], y2: zig[0][1], stroke: INK, 'stroke-width': WEIGHTS.utility }));
311
+ shapes.push(el('path', { d: `M ${zig.map(P).join(' L ')}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
312
+ const t = [-o[0], -o[1]];
313
+ if (t[0]) {
314
+ const anchor = t[0] < 0 ? 'end' : 'start';
315
+ shapes.push(text(x + t[0] * 6, y - 4, tag, { size: 6, anchor }));
316
+ if (set) shapes.push(text(x + t[0] * 6, y + 3, set, { size: 6, anchor }));
317
+ } else if (t[1] > 0) {
318
+ shapes.push(text(x, y + 10, tag, { size: 6, anchor: 'middle' }));
319
+ if (set) shapes.push(text(x, y + 17, set, { size: 6, anchor: 'middle' }));
320
+ } else {
321
+ shapes.push(text(x, y - 13, tag, { size: 6, anchor: 'middle' }));
322
+ if (set) shapes.push(text(x, y - 6, set, { size: 6, anchor: 'middle' }));
323
+ }
280
324
  return tag;
281
325
  },
282
326
  orifice(id, x, y) {
@@ -307,12 +351,8 @@ export function createSheet(opts = {}) {
307
351
  }
308
352
  shapes.push(text(x, y - 1, letters, { size: 6, anchor: 'middle' }));
309
353
  shapes.push(text(x, y + 7, number, { size: 6, anchor: 'middle' }));
310
- // A signal leaving the bubble at mid-height would strike a right-side tag,
311
- // and a side tag can hit the next symbol; below or left keep it clear.
312
- const place = opts2.tag ?? 'right';
313
- if (place === 'below') shapes.push(text(x, y + r + 9, tag, { size: 6, anchor: 'middle' }));
314
- else if (place === 'left') shapes.push(text(x - r - 3, y + 2, tag, { size: 6, anchor: 'end' }));
315
- else shapes.push(text(x + r + 3, y + 2, tag, { size: 6 }));
354
+ // The bubble already reads as the tag (letters over number), so no second
355
+ // tag is printed beside it. The old `tag` placement option is accepted and ignored.
316
356
  return tag;
317
357
  },
318
358
  offpage(label, x, y, dir = 'out', w = 22) {
@@ -339,7 +379,7 @@ export function createSheet(opts = {}) {
339
379
  },
340
380
  vent(x, y) {
341
381
  kinds.add('vent');
342
- marks.push(comment('symbol kind=vent id=ATM'));
382
+ marks.push(comment(`symbol kind=vent id=ATM at=${x},${y}`));
343
383
  shapes.push(el('path', { d: `M ${x} ${y} L ${x} ${y - 8} A 5 5 0 0 1 ${x + 10} ${y - 8} L ${x + 10} ${y - 2}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.utility }));
344
384
  // The label sits left of the vent pipe so it never touches the arc.
345
385
  shapes.push(text(x - 5, y - 1, 'ATM', { size: 6, anchor: 'end' }));
@@ -377,7 +417,7 @@ export function createSheet(opts = {}) {
377
417
  const [x2, y2] = pts[pts.length - 1];
378
418
  return api.add(kind, id, x1 + (x2 - x1) * at, y1 + (y2 - y1) * at, id);
379
419
  },
380
- valve(body, actuator, id, x, y, fail = 'FC', orient = 'h') {
420
+ valve(body, actuator, id, x, y, fail = 'FC', orient = 'h', tagSide = 'left') {
381
421
  kinds.add(`valve.${body}.${actuator}`);
382
422
  marks.push(comment(`symbol kind=valve.${body}.${actuator} id=${id} fail=${fail}`));
383
423
  const v = orient === 'v';
@@ -408,12 +448,23 @@ export function createSheet(opts = {}) {
408
448
  }
409
449
  }
410
450
  if (body !== 'check' || fail !== 'FC') shapes.push(text(x + 8, y - 6, fail, { size: 6 }));
411
- shapes.push(text(v ? x : x - 6, y + (v ? 16 : 14), id, { size: 7, anchor: 'middle' }));
451
+ // On a vertical pipe a tag below the body would sit on the pipe, so it goes
452
+ // beside it: left by default, or right past the actuator (and fail letters).
453
+ if (v && tagSide === 'right') shapes.push(text(x + (body === 'check' ? 7 : 15), y + 2.5, id, { size: 7, anchor: 'start' }));
454
+ else if (v) shapes.push(text(x - 7, y + 2.5, id, { size: 7, anchor: 'end' }));
455
+ else shapes.push(text(x - 6, y + 14, id, { size: 7, anchor: 'middle' }));
412
456
  return id;
413
457
  },
414
- arrow(x, y, clear = true) {
415
- marks.push(comment(`arrow at=${x},${y} clear=${clear ? 'true' : 'false'}`));
416
- shapes.push(el('path', { d: `M ${x - 4} ${y - 2} L ${x} ${y} L ${x - 4} ${y + 2}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.process }));
458
+ // An open chevron with its tip at (x, y), pointing the way the flow runs.
459
+ arrow(x, y, clear = true, dir = 'right') {
460
+ const wings = {
461
+ right: [[-4, -2], [-4, 2]], left: [[4, -2], [4, 2]],
462
+ up: [[-2, 4], [2, 4]], down: [[-2, -4], [2, -4]],
463
+ }[dir];
464
+ if (!wings) throw new Error(`arrow direction ${dir} is not right, left, up or down`);
465
+ marks.push(comment(`arrow at=${x},${y} clear=${clear ? 'true' : 'false'} dir=${dir}`));
466
+ const [[ax, ay], [bx, by]] = wings;
467
+ shapes.push(el('path', { d: `M ${x + ax} ${y + ay} L ${x} ${y} L ${x + bx} ${y + by}`, fill: 'none', stroke: INK, 'stroke-width': WEIGHTS.process }));
417
468
  },
418
469
  hop(x, y, r = 1.5) {
419
470
  marks.push(comment(`hop r=${r}`));