@litfamily/litmuse 0.1.8 → 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 +5 -0
- package/README.md +17 -9
- package/README_ko-KR.md +17 -9
- package/docs/assets/card/after.webp +0 -0
- package/docs/assets/card/idle.webp +0 -0
- package/docs/assets/card/working.webp +0 -0
- package/docs/assets/handoff/latest.webp +0 -0
- package/docs/assets/handoff/saved.webp +0 -0
- package/docs/assets/readme/demo.webp +0 -0
- package/package.json +1 -1
- package/plugin/.muse-plugin/plugin.json +1 -1
- package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +42 -34
- package/plugin/skills/lit-schematic/references/kit-api.md +15 -0
- package/plugin/skills/lit-schematic/references/pid-drafting.md +6 -2
- package/plugin/skills/lit-schematic/references/pid-symbols.md +10 -2
- package/plugin/skills/lit-schematic/references/verification.md +9 -2
- package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +116 -8
- package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +86 -35
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 0.1.8 — 2026-11-04
|
|
4
9
|
|
|
5
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.
|
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> <a href="#quick-start">Quick start</a> <a href="#the-status-card">Status card</a> <a href="#automatic-handoff">Handoff</a> <a href="#safety-and-uninstall">Safety</a> <a href="./README_ko-KR.md">한국어</a></p>
|
|
14
|
+
<p align="center"><a href="#in-the-muse-app-too">Muse app</a> <a href="#install">Install</a> <a href="#quick-start">Quick start</a> <a href="#the-status-card">Status card</a> <a href="#automatic-handoff">Handoff</a> <a href="#safety-and-uninstall">Safety</a> <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
|
|
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="
|
|
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="
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
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> <a href="#빠른-시작">빠른 시작</a> <a href="#상태-카드">상태 카드</a> <a href="#자동-핸드오프">핸드오프</a> <a href="#안전과-제거">안전</a> <a href="./README.md">English</a></p>
|
|
14
|
+
<p align="center"><a href="#muse-앱에서도">Muse 앱</a> <a href="#설치">설치</a> <a href="#빠른-시작">빠른 시작</a> <a href="#상태-카드">상태 카드</a> <a href="#자동-핸드오프">핸드오프</a> <a href="#안전과-제거">안전</a> <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="
|
|
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="활성 경로가
|
|
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
|
|
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="
|
|
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
|
|
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="
|
|
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="
|
|
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, 상용·예비
|
|
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/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"name": "litmuse",
|
|
4
4
|
"displayName": "LitMuse",
|
|
5
|
-
"version": "0.1.
|
|
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
|
|
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
|
|
24
|
-
sheet.offpage('FROM UNLOADING', 12,
|
|
25
|
-
sheet.pipe([[76,
|
|
26
|
-
sheet.tank('TK-101',
|
|
27
|
-
sheet.vent(
|
|
28
|
-
sheet.instrument('LT-101', 78, 140
|
|
29
|
-
sheet.signal([[91, 140], [
|
|
30
|
-
sheet.instrument('LIA-101', 78, 80, { dcs: true
|
|
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([[
|
|
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',
|
|
38
|
-
sheet.arrow(
|
|
37
|
+
sheet.strainer('STR-101', 157, 182);
|
|
38
|
+
sheet.arrow(96, 182, true);
|
|
39
39
|
|
|
40
|
-
// Duty and standby pump branches
|
|
41
|
-
//
|
|
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,
|
|
46
|
-
sheet.pipe([[
|
|
47
|
-
sheet.pump('P-101B',
|
|
48
|
-
sheet.pipe([[
|
|
49
|
-
sheet.valve('check', 'auto', 'XV-101B',
|
|
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
|
|
53
|
-
|
|
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([[
|
|
56
|
-
sheet.
|
|
57
|
-
sheet.
|
|
58
|
-
sheet.
|
|
59
|
-
sheet.
|
|
60
|
-
sheet.
|
|
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,
|
|
63
|
-
sheet.offpage('TO
|
|
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
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
sheet.pipe([[
|
|
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
|
-
|
|
14
|
-
pipe
|
|
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.
|
|
9
|
-
|
|
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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
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
|
|
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
|
|
235
|
-
|
|
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 -
|
|
142
|
-
s.push(el('path', { d: `M ${x -
|
|
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
|
|
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
|
|
183
|
-
// clears the
|
|
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)) *
|
|
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 +
|
|
189
|
-
out.push(text(xx +
|
|
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) *
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
shapes.push(
|
|
279
|
-
|
|
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
|
-
//
|
|
311
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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}`));
|