@litfamily/litopencode 1.0.30 → 1.0.32
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 +28 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/dist/activation-probe.js +1 -0
- package/dist/activation-prompt-utils.d.ts +1 -1
- package/dist/activation-reentry.d.ts +20 -0
- package/dist/activation-reentry.js +110 -1
- package/dist/activation-routing.d.ts +2 -1
- package/dist/activation-routing.js +100 -11
- package/dist/activation-workflow-prompts.d.ts +4 -0
- package/dist/activation-workflow-prompts.js +30 -0
- package/dist/activation.d.ts +1 -1
- package/dist/activation.js +44 -5
- package/dist/adhd-next.d.ts +51 -0
- package/dist/adhd-next.js +150 -0
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +7 -1
- package/dist/cli/native-skill-integrity.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/commands.d.ts +28 -1
- package/dist/commands.js +48 -1
- package/dist/features.d.ts +80 -1
- package/dist/features.js +105 -1
- package/dist/ledger.js +12 -1
- package/dist/secret-shapes.js +5 -5
- package/dist/skills.d.ts +22 -1
- package/dist/skills.js +36 -0
- package/dist/start-work-typed.d.ts +34 -0
- package/dist/start-work-typed.js +156 -0
- package/dist/state.d.ts +2 -0
- package/dist/state.js +2 -0
- package/dist/tools.js +22 -2
- package/dist/tui/mission-band.d.ts +20 -1
- package/dist/tui/mission-band.js +73 -16
- package/dist/tui/mission-looks.d.ts +38 -0
- package/dist/tui/mission-looks.js +73 -0
- package/dist/tui/mission-model.d.ts +2 -0
- package/dist/tui/mission-model.js +3 -1
- package/dist/tui/mission-rainbow.d.ts +33 -0
- package/dist/tui/mission-rainbow.js +90 -0
- package/dist/tui/mission-source.d.ts +48 -0
- package/dist/tui/mission-source.js +184 -2
- package/dist/tui/mission-text.d.ts +2 -0
- package/dist/tui/mission-text.js +9 -1
- package/dist/tui/mission-threads.d.ts +50 -0
- package/dist/tui/mission-threads.js +119 -0
- package/dist/tui.js +84 -6
- package/dist/uiux-visual-catalog.js +8 -1
- package/dist/v2/session-hooks.js +38 -5
- package/dist/workflow-stack.d.ts +53 -0
- package/dist/workflow-stack.js +196 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/skills/lit-schematic.webp +0 -0
- package/docs/reference-Ko-KR.md +30 -2
- package/docs/reference.md +34 -2
- package/package.json +1 -1
- package/skills/frontend-ui-ux/SKILL.md +3 -2
- package/skills/frontend-ui-ux/kit/components.css +414 -0
- package/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
- package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
- package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
- package/skills/frontend-ui-ux/references/house-styles.md +236 -0
- package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
- package/skills/frontend-ui-ux/references/production.md +3 -1
- package/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
- package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
- package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
- package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
- package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
- package/skills/lit-adhd/SKILL.md +208 -0
- package/skills/lit-adhd/references/brief-format.md +43 -0
- package/skills/lit-adhd/references/card-examples.md +82 -0
- package/skills/lit-adhd/references/status-taxonomy.md +29 -0
- package/skills/lit-adhd/scripts/rewind.mjs +835 -0
- package/skills/lit-diagram-drawer/SKILL.md +4 -3
- package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
- package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
- package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
- package/skills/lit-docx/SKILL.md +2 -1
- package/skills/lit-handoff/SKILL.md +2 -0
- package/skills/lit-hijack/SKILL.md +422 -0
- package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
- package/skills/lit-hijack/references/clean-room-build.md +111 -0
- package/skills/lit-hijack/references/distil-and-licence.md +100 -0
- package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
- package/skills/lit-hijack/references/parity-and-report.md +110 -0
- package/skills/lit-hijack/scripts/hijack.mjs +993 -0
- package/skills/lit-plan/SKILL.md +2 -0
- package/skills/lit-pptx/SKILL.md +27 -11
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
- package/skills/lit-pptx/references/density-and-fill.md +9 -3
- package/skills/lit-pptx/references/direction-step.md +21 -6
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
- package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/skills/lit-pptx/references/layout-families.md +65 -47
- package/skills/lit-pptx/references/title-treatments.md +83 -134
- package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
- package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
- package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
- package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
- package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
- package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
- package/skills/lit-pptx/references/tonalities/night.md +1 -1
- package/skills/lit-pptx/references/tonalities/paper.md +2 -2
- package/skills/lit-pptx/references/tonalities/riso.md +137 -0
- package/skills/lit-pptx/references/tonalities/signal.md +3 -3
- package/skills/lit-pptx/references/tonalities/studio.md +7 -6
- package/skills/lit-pptx/run.mjs +2 -1
- package/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
- package/skills/lit-pptx/scripts/craft_extras.py +56 -3
- package/skills/lit-pptx/scripts/deck_output.py +75 -90
- package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
- package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/skills/lit-pptx/scripts/qa_deck.py +10 -11
- package/skills/lit-pptx/scripts/render_pages.py +118 -0
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/skills/lit-recap/SKILL.md +4 -0
- package/skills/lit-schematic/SKILL.md +118 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/skills/lit-schematic/references/journal-figures.md +72 -0
- package/skills/lit-schematic/references/kit-api.md +134 -0
- package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/skills/lit-schematic/references/verification.md +100 -0
- package/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
- package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/skills/lit-schematic/scripts/render.mjs +233 -0
- package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/skills/lit-scientific-visualization/SKILL.md +27 -4
- package/skills/lit-typographic-motion/SKILL.md +19 -17
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
- package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
- package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
- package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
- package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
- package/skills/lit-typographic-motion/engine/board.mjs +138 -0
- package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
- package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
- package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
- package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
- package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
- package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
- package/skills/lit-typographic-motion/engine/house.mjs +42 -0
- package/skills/lit-typographic-motion/engine/look.mjs +65 -8
- package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
- package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
- package/skills/lit-typographic-motion/engine/score.mjs +594 -0
- package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
- package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
- package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
- package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
- package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
- package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
- package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
- package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
- package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
- package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
- package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
- package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
- package/skills/lit-typographic-motion/engine/text.mjs +1 -1
- package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
- package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/skills/lit-typographic-motion/gate.mjs +18 -2
- package/skills/lit-typographic-motion/probe.mjs +13 -1
- package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
- package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
- package/skills/lit-typographic-motion/references/cover.md +56 -13
- package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
- package/skills/lit-typographic-motion/references/depth.md +112 -0
- package/skills/lit-typographic-motion/references/runtime.md +21 -6
- package/skills/lit-typographic-motion/references/sound.md +76 -0
- package/skills/lit-typographic-motion/references/stage.md +50 -7
- package/skills/lit-typographic-motion/references/studio.md +162 -0
- package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
- package/skills/lit-typographic-motion/references/treatment.md +108 -9
- package/skills/lit-typographic-motion/render.mjs +60 -3
- package/skills/lit-typographic-motion/requirements-audio.txt +3 -3
- package/skills/lit-typographic-motion/styles/paper.json +185 -0
- package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
- package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
- package/skills/lit-typographic-motion/styles/tidal.json +187 -0
- package/skills/litresearch/SKILL.md +2 -0
- package/skills/managed-skill-manifest.json +520 -91
- package/skills/readme-studio/SKILL.md +1 -1
- package/skills/readme-studio/templates/hyperframes/package-lock.json +3 -3
- package/skills/readme-studio/templates/remotion/package-lock.json +3 -3
- package/skills/review-work/SKILL.md +2 -0
- package/skills/start-work/SKILL.md +8 -0
- package/skills/workflow-loop/SKILL.md +4 -0
- package/tools/check-pack-payload.mjs +4 -2
- package/tools/payload-substance-parity.json +4 -3
- package/tools/qa-cover-recipes.mjs +4 -3
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// Isometric (axonometric) equipment illustration: solids, not wireframes.
|
|
2
|
+
//
|
|
3
|
+
// World units are the real object's millimetres, x and y on the floor, z up. The camera is a
|
|
4
|
+
// parallel projection with a free azimuth and elevation; every part is filled so what is in
|
|
5
|
+
// front hides what is behind, shaded by how its faces turn to one fixed light, outlined by its
|
|
6
|
+
// silhouette, and painted far to near. Turned parts (shells, heads, nozzles, flanges, shafts)
|
|
7
|
+
// shade continuously around their axis, so curved bodies never step in tone. A vessel can be
|
|
8
|
+
// opened by a window cutaway: its inner wall, internals and liquid show through the window,
|
|
9
|
+
// the cut wall shows its thickness, and everything outside the window stays closed.
|
|
10
|
+
//
|
|
11
|
+
// Labels never sit on the object: callouts stand in columns beside it, and each leader runs
|
|
12
|
+
// from its label to a dot on the part it names, sorted so no two leaders cross.
|
|
13
|
+
|
|
14
|
+
import { el, esc, n, pathOf, textBox } from "./svg.mjs";
|
|
15
|
+
import { FONT_LATIN, ISO, MATERIAL, fontFor } from "./theme.mjs";
|
|
16
|
+
|
|
17
|
+
const RAD = Math.PI / 180;
|
|
18
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
19
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
20
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
21
|
+
const mul = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
|
|
22
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
23
|
+
const len = (a) => Math.hypot(a[0], a[1], a[2]);
|
|
24
|
+
const unit = (a) => { const l = len(a) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
|
|
25
|
+
export const vec = { dot, add, sub, mul, cross, len, unit };
|
|
26
|
+
|
|
27
|
+
// Callout leaders keep at least this far apart on the page (the QA gate fails under 1.2 mm).
|
|
28
|
+
const LEADER_GAP = 1.4;
|
|
29
|
+
const segDist = (p, a, b) => {
|
|
30
|
+
const dx = b[0] - a[0];
|
|
31
|
+
const dy = b[1] - a[1];
|
|
32
|
+
const l2 = dx * dx + dy * dy;
|
|
33
|
+
const t = l2 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2)) : 0;
|
|
34
|
+
return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t);
|
|
35
|
+
};
|
|
36
|
+
function crosses([a, b], [c, d]) {
|
|
37
|
+
const den = (b[0] - a[0]) * (d[1] - c[1]) - (b[1] - a[1]) * (d[0] - c[0]);
|
|
38
|
+
if (Math.abs(den) < 1e-9) return false;
|
|
39
|
+
const t = ((c[0] - a[0]) * (d[1] - c[1]) - (c[1] - a[1]) * (d[0] - c[0])) / den;
|
|
40
|
+
const u = ((c[0] - a[0]) * (b[1] - a[1]) - (c[1] - a[1]) * (b[0] - a[0])) / den;
|
|
41
|
+
return t > 0.001 && t < 0.999 && u > 0.001 && u < 0.999;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const AXES = { x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, 1], "-x": [-1, 0, 0], "-y": [0, -1, 0], "-z": [0, 0, -1] };
|
|
45
|
+
const axisOf = (a) => unit(typeof a === "string" ? AXES[a] : a);
|
|
46
|
+
|
|
47
|
+
export function frameAlong(origin, axis) {
|
|
48
|
+
const a = axisOf(axis);
|
|
49
|
+
const helper = Math.abs(a[2]) > 0.9 ? [1, 0, 0] : [0, 0, 1];
|
|
50
|
+
const u = unit(cross(helper, a));
|
|
51
|
+
const v = cross(a, u);
|
|
52
|
+
return { o: origin, a, u, v };
|
|
53
|
+
}
|
|
54
|
+
const ringPoint = (F, s, r, t) => add(add(F.o, mul(F.a, s)), add(mul(F.u, r * Math.cos(t)), mul(F.v, r * Math.sin(t))));
|
|
55
|
+
const radial = (F, t) => add(mul(F.u, Math.cos(t)), mul(F.v, Math.sin(t)));
|
|
56
|
+
|
|
57
|
+
function hexToRgb(h) { const v = h.replace("#", ""); return [0, 2, 4].map((i) => parseInt(v.slice(i, i + 2), 16)); }
|
|
58
|
+
const rgbToHex = (c) => `#${c.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("")}`;
|
|
59
|
+
const mix = (a, b, t) => { const p = hexToRgb(a); const q = hexToRgb(b); return rgbToHex(p.map((v, i) => v + (q[i] - v) * t)); };
|
|
60
|
+
|
|
61
|
+
// A tone is a ramp from the face turned away from the light to the face turned toward it.
|
|
62
|
+
// Material tones (steel, copper, ...) are the same in both palettes; the generic roles (lo, mid,
|
|
63
|
+
// hi, accent, cavity, section, liquid) are concrete, painted steel, stainless and signal red in
|
|
64
|
+
// the default material palette, and greys with one blue accent in the opt-in grey palette.
|
|
65
|
+
const rampFrom = (base) => [mix(base, "#000000", 0.3), mix(base, "#000000", 0.12), base, mix(base, "#ffffff", 0.28), mix(base, "#ffffff", 0.52)];
|
|
66
|
+
const MATERIAL_RAMPS = Object.fromEntries(["steel", "stainless", "frame", "copper", "brass", "cast", "insulation", "rubber", "glass", "concrete"].map((name) => [name, rampFrom(MATERIAL[name])]));
|
|
67
|
+
const SHARED_RAMPS = {
|
|
68
|
+
warm: [mix("#e8c9b0", "#000000", 0.08), "#ecd3be", "#f3e3d6", "#faf2ec"],
|
|
69
|
+
cool: [mix("#c9dbe6", "#000000", 0.08), "#d3e2ec", "#e1ecf3", "#f0f6f9"],
|
|
70
|
+
...MATERIAL_RAMPS,
|
|
71
|
+
};
|
|
72
|
+
const PALETTES = Object.freeze({
|
|
73
|
+
material: {
|
|
74
|
+
lo: MATERIAL_RAMPS.concrete,
|
|
75
|
+
mid: MATERIAL_RAMPS.steel,
|
|
76
|
+
hi: MATERIAL_RAMPS.stainless,
|
|
77
|
+
cavity: [mix(MATERIAL.stainless, "#000000", 0.28), mix(MATERIAL.stainless, "#000000", 0.2), mix(MATERIAL.stainless, "#000000", 0.12), mix(MATERIAL.stainless, "#000000", 0.05)],
|
|
78
|
+
section: [mix(MATERIAL.stainless, "#ffffff", 0.2), mix(MATERIAL.stainless, "#ffffff", 0.2)],
|
|
79
|
+
accent: rampFrom(MATERIAL.accent),
|
|
80
|
+
liquid: [mix(MATERIAL.liquid, "#1f5f86", 0.35), MATERIAL.liquid, mix(MATERIAL.liquid, "#ffffff", 0.45)],
|
|
81
|
+
...SHARED_RAMPS,
|
|
82
|
+
},
|
|
83
|
+
grey: {
|
|
84
|
+
lo: [mix(ISO.shade[0], "#000000", 0.12), ISO.shade[0], ISO.shade[1], ISO.shade[2]],
|
|
85
|
+
mid: [ISO.shade[0], ISO.shade[1], ISO.shade[2], ISO.shade[3], ISO.top],
|
|
86
|
+
hi: [ISO.shade[1], ISO.shade[2], ISO.shade[3], ISO.top, "#ffffff"],
|
|
87
|
+
cavity: [...ISO.cavity],
|
|
88
|
+
section: [ISO.section, ISO.section],
|
|
89
|
+
accent: [mix(ISO.accent, "#ffffff", 0.45), mix(ISO.accent, "#ffffff", 0.62), mix(ISO.accent, "#ffffff", 0.78), mix(ISO.accent, "#ffffff", 0.9)],
|
|
90
|
+
liquid: [mix(ISO.liquid, "#5b8aa6", 0.35), ISO.liquid, mix(ISO.liquid, "#ffffff", 0.5)],
|
|
91
|
+
...SHARED_RAMPS,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
export const PALETTE_NAMES = Object.freeze(Object.keys(PALETTES));
|
|
95
|
+
export const TONES = Object.freeze(Object.keys(PALETTES.material));
|
|
96
|
+
function rampColor(ramps, tone, t) {
|
|
97
|
+
const ramp = ramps[tone] ?? ramps.mid;
|
|
98
|
+
const x = Math.max(0, Math.min(1, t)) * (ramp.length - 1);
|
|
99
|
+
const i = Math.min(ramp.length - 2, Math.floor(x));
|
|
100
|
+
return mix(ramp[i], ramp[i + 1], x - i);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hull(points) {
|
|
104
|
+
const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
105
|
+
if (pts.length < 3) return pts;
|
|
106
|
+
const crossZ = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
107
|
+
const lower = [];
|
|
108
|
+
for (const p of pts) { while (lower.length >= 2 && crossZ(lower.at(-2), lower.at(-1), p) <= 0) lower.pop(); lower.push(p); }
|
|
109
|
+
const upper = [];
|
|
110
|
+
for (let i = pts.length - 1; i >= 0; i--) { const p = pts[i]; while (upper.length >= 2 && crossZ(upper.at(-2), upper.at(-1), p) <= 0) upper.pop(); upper.push(p); }
|
|
111
|
+
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ------------------------------------------------------------------ profiles
|
|
115
|
+
|
|
116
|
+
// A 2:1 ellipsoidal head closing a shell of radius R, as profile points from the tangent line
|
|
117
|
+
// (s = 0) to the crown (s = R / 2), marked smooth so no crease is drawn across it.
|
|
118
|
+
export function ellipsoidalHead(R, { steps = 10, depth = R / 2 } = {}) {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (let i = 0; i <= steps; i++) {
|
|
121
|
+
const t = (i / steps) * (Math.PI / 2);
|
|
122
|
+
out.push([depth * Math.sin(t), R * Math.cos(t), i > 0 && i < steps ? 1 : 0]);
|
|
123
|
+
}
|
|
124
|
+
out[out.length - 1][1] = 0;
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Shell with an ellipsoidal head at each end, profile from s = 0 (bottom crown) upward.
|
|
129
|
+
export function vesselProfile(R, straight, { bottom = "ellipse", top = "ellipse" } = {}) {
|
|
130
|
+
const head = ellipsoidalHead(R);
|
|
131
|
+
const headDepth = head.at(-1)[0];
|
|
132
|
+
const out = [];
|
|
133
|
+
if (bottom === "ellipse") for (const [s, r, sm] of [...head].reverse()) out.push([headDepth - s, r, sm]);
|
|
134
|
+
else out.push([0, 0], [0, R]);
|
|
135
|
+
const base = bottom === "ellipse" ? headDepth : 0;
|
|
136
|
+
out.push([base + straight, R]);
|
|
137
|
+
if (top === "ellipse") for (const [s, r, sm] of head.slice(1)) out.push([base + straight + s, r, sm]);
|
|
138
|
+
else out.push([base + straight, 0]);
|
|
139
|
+
return { profile: out, length: base + straight + (top === "ellipse" ? headDepth : 0), tangent: [base, base + straight] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ------------------------------------------------------------------ the view
|
|
143
|
+
|
|
144
|
+
export function createIsoView({ azimuth = 45, elevation = 30, light = null, palette = "material" } = {}) {
|
|
145
|
+
const ramps = PALETTES[palette];
|
|
146
|
+
if (!ramps) throw new Error(`createIsoView: unknown palette "${palette}"; use ${PALETTE_NAMES.join(" or ")}`);
|
|
147
|
+
const paint = (tone, t) => rampColor(ramps, tone, t);
|
|
148
|
+
const A = azimuth * RAD;
|
|
149
|
+
const E = elevation * RAD;
|
|
150
|
+
const R = [Math.sin(A), -Math.cos(A), 0];
|
|
151
|
+
const D = [Math.cos(A) * Math.sin(E), Math.sin(A) * Math.sin(E), -Math.cos(E)];
|
|
152
|
+
const V = [Math.cos(A) * Math.cos(E), Math.sin(A) * Math.cos(E), Math.sin(E)];
|
|
153
|
+
const L = light ? unit(light) : unit(add(add(mul(R, -0.5), mul(D, -0.75)), mul(V, 0.45)));
|
|
154
|
+
let k = 1;
|
|
155
|
+
let origin = [0, 0];
|
|
156
|
+
const items = [];
|
|
157
|
+
const callouts = [];
|
|
158
|
+
const warnings = [];
|
|
159
|
+
const project = (p) => [origin[0] + dot(p, R) * k, origin[1] + dot(p, D) * k];
|
|
160
|
+
const depth = (p) => dot(p, V);
|
|
161
|
+
const shade = (normal, tone) => paint(tone, (dot(unit(normal), L) + 0.35) / 1.35);
|
|
162
|
+
|
|
163
|
+
// Items keep world geometry and draw lazily, after fit() has fixed the projection.
|
|
164
|
+
// `layer` orders groups before depth: the ground (-1) is painted before anything resting on
|
|
165
|
+
// it, whatever the depth of its centre says; halos sit below that (-2).
|
|
166
|
+
// `group` names the assembly an item belongs to (shell, internals, drive...): interactive pages
|
|
167
|
+
// toggle and explode by it, motion moves it; static drawings only carry it as data-group.
|
|
168
|
+
function item({ name, center, bias = 0, layer = 0, draw, points, container = null, group = null }) {
|
|
169
|
+
const it = { name, center, layer, key: depth(center) + bias, draw, points, children: [], container, group };
|
|
170
|
+
if (container) container.children.push(it);
|
|
171
|
+
else items.push(it);
|
|
172
|
+
return it;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function lathe({ name, origin: o = [0, 0, 0], axis = "z", profile, tone = "mid", steps = 96, bias = 0, layer = 0, seams = [], cut = null, wall = 3, outline = true, opacity = 1, into = null, group = null }) {
|
|
176
|
+
const F = frameAlong(o, axis);
|
|
177
|
+
const prof = profile.map(([s, r, smooth]) => [s, r, smooth ? 1 : 0]);
|
|
178
|
+
const sMid = (prof[0][0] + prof.at(-1)[0]) / 2;
|
|
179
|
+
const center = add(F.o, mul(F.a, sMid));
|
|
180
|
+
const angles = Array.from({ length: steps + 1 }, (_, j) => (j / steps) * Math.PI * 2);
|
|
181
|
+
const facing = Math.atan2(dot(V, F.v), dot(V, F.u));
|
|
182
|
+
const inCut = (t, s) => {
|
|
183
|
+
if (!cut) return false;
|
|
184
|
+
const half = ((cut.span ?? 100) / 2) * RAD;
|
|
185
|
+
const c = facing + (cut.turn ?? 0) * RAD;
|
|
186
|
+
let d = Math.atan2(Math.sin(t - c), Math.cos(t - c));
|
|
187
|
+
return Math.abs(d) < half && s >= cut.from - 1e-6 && s <= cut.to + 1e-6;
|
|
188
|
+
};
|
|
189
|
+
const worldPoints = [];
|
|
190
|
+
for (const [s, r] of prof) for (const t of angles) worldPoints.push(ringPoint(F, s, r, t));
|
|
191
|
+
const handle = {
|
|
192
|
+
name, frame: F, profile: prof, center,
|
|
193
|
+
point: (s, r, t = facing) => ringPoint(F, s, r, t),
|
|
194
|
+
facingAngle: facing,
|
|
195
|
+
surface: (s, side = 0) => {
|
|
196
|
+
let r = 0;
|
|
197
|
+
for (let i = 0; i < prof.length - 1; i++) {
|
|
198
|
+
const [s0, r0] = prof[i];
|
|
199
|
+
const [s1, r1] = prof[i + 1];
|
|
200
|
+
if (s >= Math.min(s0, s1) && s <= Math.max(s0, s1) && s1 !== s0) { r = r0 + ((r1 - r0) * (s - s0)) / (s1 - s0); break; }
|
|
201
|
+
}
|
|
202
|
+
return ringPoint(F, s, r, facing + side * RAD);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const quad = (i, j, rOffset, inward) => {
|
|
206
|
+
const [s0, r0] = prof[i];
|
|
207
|
+
const [s1, r1] = prof[i + 1];
|
|
208
|
+
const ra = Math.max(0, r0 - rOffset);
|
|
209
|
+
const rb = Math.max(0, r1 - rOffset);
|
|
210
|
+
const t0 = angles[j];
|
|
211
|
+
const t1 = angles[j + 1];
|
|
212
|
+
const tm = (t0 + t1) / 2;
|
|
213
|
+
let nrm = add(mul(radial(F, tm), s1 - s0), mul(F.a, -(r1 - r0)));
|
|
214
|
+
if (len(nrm) < 1e-9) return null;
|
|
215
|
+
if (inward) nrm = mul(nrm, -1);
|
|
216
|
+
return { pts: [ringPoint(F, s0, ra, t0), ringPoint(F, s0, ra, t1), ringPoint(F, s1, rb, t1), ringPoint(F, s1, rb, t0)], nrm, s: (s0 + s1) / 2, t: tm };
|
|
217
|
+
};
|
|
218
|
+
const drawQuads = (list, tone0, extra = {}) => list.map((q) => {
|
|
219
|
+
const color = shade(q.nrm, tone0);
|
|
220
|
+
return el("path", { d: pathOf(q.pts.map(project), true), fill: color, stroke: color, "stroke-width": 0.09, "stroke-linejoin": "round", ...extra });
|
|
221
|
+
}).join("");
|
|
222
|
+
const sortQuads = (list) => list.sort((a, b) => depth(add(a.pts[0], a.pts[2])) - depth(add(b.pts[0], b.pts[2])));
|
|
223
|
+
|
|
224
|
+
const outer = [];
|
|
225
|
+
const inner = [];
|
|
226
|
+
for (let i = 0; i < prof.length - 1; i++) {
|
|
227
|
+
for (let j = 0; j < steps; j++) {
|
|
228
|
+
const q = quad(i, j, 0, false);
|
|
229
|
+
if (!q) continue;
|
|
230
|
+
if (cut && inCut(q.t, q.s)) {
|
|
231
|
+
const qi = quad(i, j, wall, true);
|
|
232
|
+
if (qi) inner.push(qi);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (dot(q.nrm, V) > 0) outer.push(q);
|
|
236
|
+
if (cut) {
|
|
237
|
+
const qi = quad(i, j, wall, true);
|
|
238
|
+
if (qi && dot(qi.nrm, V) > 0 && prof[i][0] >= cut.from - wall * 4 && prof[i + 1][0] <= cut.to + wall * 4) inner.push(qi);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const visibleInner = inner.filter((q) => dot(q.nrm, V) > 0);
|
|
243
|
+
// Creases: rings at sharp profile vertices where both neighbouring faces are visible.
|
|
244
|
+
const ringLine = (s, r, keep) => {
|
|
245
|
+
const pts = [];
|
|
246
|
+
const runs = [];
|
|
247
|
+
for (const t of angles) {
|
|
248
|
+
if (keep(t)) pts.push(project(ringPoint(F, s, r, t)));
|
|
249
|
+
else if (pts.length) { runs.push([...pts]); pts.length = 0; }
|
|
250
|
+
}
|
|
251
|
+
if (pts.length) runs.push(pts);
|
|
252
|
+
return runs.filter((p) => p.length > 1).map((p) => pathOf(p)).join("");
|
|
253
|
+
};
|
|
254
|
+
const faceVisible = (i, t) => {
|
|
255
|
+
if (i < 0 || i >= prof.length - 1) return false;
|
|
256
|
+
const [s0, r0] = prof[i];
|
|
257
|
+
const [s1, r1] = prof[i + 1];
|
|
258
|
+
const nrm = add(mul(radial(F, t), s1 - s0), mul(F.a, -(r1 - r0)));
|
|
259
|
+
return len(nrm) > 1e-9 && dot(nrm, V) > 0 && !inCut(t, (s0 + s1) / 2);
|
|
260
|
+
};
|
|
261
|
+
const drawCreases = () => {
|
|
262
|
+
const out = [];
|
|
263
|
+
for (let i = 1; i < prof.length - 1; i++) {
|
|
264
|
+
if (prof[i][2]) continue;
|
|
265
|
+
const [s, r] = prof[i];
|
|
266
|
+
if (r <= 0) continue;
|
|
267
|
+
out.push(ringLine(s, r, (t) => faceVisible(i - 1, t) && faceVisible(i, t)));
|
|
268
|
+
}
|
|
269
|
+
for (const s of seams) {
|
|
270
|
+
const seg = prof.findIndex(([s0], i) => i < prof.length - 1 && s > Math.min(s0, prof[i + 1][0]) && s < Math.max(s0, prof[i + 1][0]));
|
|
271
|
+
if (seg < 0) continue;
|
|
272
|
+
const [s0, r0] = prof[seg];
|
|
273
|
+
const [s1, r1] = prof[seg + 1];
|
|
274
|
+
out.push(ringLine(s, r0 + ((r1 - r0) * (s - s0)) / (s1 - s0), (t) => faceVisible(seg, t)));
|
|
275
|
+
}
|
|
276
|
+
return out.filter(Boolean).join("");
|
|
277
|
+
};
|
|
278
|
+
// Cut faces: the wall thickness along the two vertical edges and the window sill.
|
|
279
|
+
const cutFaces = () => {
|
|
280
|
+
if (!cut) return "";
|
|
281
|
+
const half = ((cut.span ?? 100) / 2) * RAD;
|
|
282
|
+
const c = facing + (cut.turn ?? 0) * RAD;
|
|
283
|
+
const out = [];
|
|
284
|
+
const radiusAt = (s) => {
|
|
285
|
+
for (let i = 0; i < prof.length - 1; i++) {
|
|
286
|
+
const [s0, r0] = prof[i];
|
|
287
|
+
const [s1, r1] = prof[i + 1];
|
|
288
|
+
if (s >= Math.min(s0, s1) - 1e-6 && s <= Math.max(s0, s1) + 1e-6 && s1 !== s0) return r0 + ((r1 - r0) * (s - s0)) / (s1 - s0);
|
|
289
|
+
}
|
|
290
|
+
return prof[0][1];
|
|
291
|
+
};
|
|
292
|
+
const samples = 16;
|
|
293
|
+
for (const edge of [c - half, c + half]) {
|
|
294
|
+
const pts = [];
|
|
295
|
+
for (let m = 0; m <= samples; m++) { const s = cut.from + ((cut.to - cut.from) * m) / samples; pts.push(ringPoint(F, s, radiusAt(s), edge)); }
|
|
296
|
+
for (let m = samples; m >= 0; m--) { const s = cut.from + ((cut.to - cut.from) * m) / samples; pts.push(ringPoint(F, s, Math.max(0, radiusAt(s) - wall), edge)); }
|
|
297
|
+
out.push(el("path", { d: pathOf(pts.map(project), true), fill: ISO.section, stroke: ISO.hi, "stroke-width": 0.16, "stroke-linejoin": "round", "data-cut": "edge" }));
|
|
298
|
+
}
|
|
299
|
+
for (const s of [cut.from, cut.to]) {
|
|
300
|
+
const r = radiusAt(s);
|
|
301
|
+
const sill = [];
|
|
302
|
+
const arc = Array.from({ length: 33 }, (_, m) => c - half + (2 * half * m) / 32);
|
|
303
|
+
for (const t of arc) sill.push(ringPoint(F, s, r, t));
|
|
304
|
+
for (const t of [...arc].reverse()) sill.push(ringPoint(F, s, Math.max(0, r - wall), t));
|
|
305
|
+
const nrm = s === cut.from ? F.a : mul(F.a, -1);
|
|
306
|
+
if (dot(nrm, V) > 0) out.push(el("path", { d: pathOf(sill.map(project), true), fill: ISO.section, stroke: ISO.hi, "stroke-width": 0.16, "stroke-linejoin": "round", "data-cut": "sill" }));
|
|
307
|
+
}
|
|
308
|
+
return out.join("");
|
|
309
|
+
};
|
|
310
|
+
const container = into?.it ?? null;
|
|
311
|
+
const it = item({
|
|
312
|
+
name, center, bias, layer, container, group, points: worldPoints,
|
|
313
|
+
draw: (children) => {
|
|
314
|
+
const screen = worldPoints.map(project);
|
|
315
|
+
const outlinePath = pathOf(hull(screen), true);
|
|
316
|
+
const parts = [];
|
|
317
|
+
if (!cut) parts.push(el("path", { d: outlinePath, fill: ISO.paper, stroke: "none" }));
|
|
318
|
+
if (cut) parts.push(drawQuads(sortQuads(visibleInner), "cavity", { "data-surface": "inner" }));
|
|
319
|
+
parts.push(children);
|
|
320
|
+
parts.push(drawQuads(sortQuads(outer), tone));
|
|
321
|
+
parts.push(el("path", { d: drawCreases() || "M0 0", fill: "none", stroke: ISO.mid, "stroke-width": 0.12, "stroke-linecap": "round", "data-crease": "1" }));
|
|
322
|
+
parts.push(cutFaces());
|
|
323
|
+
if (outline) parts.push(el("path", { d: outlinePath, fill: "none", stroke: ISO.hi, "stroke-width": 0.2, "stroke-linejoin": "round", "data-outline": "1" }));
|
|
324
|
+
const hullPts = hull(screen).map(([x, y]) => `${n(x)},${n(y)}`).join(" ");
|
|
325
|
+
return el("g", { "data-part": name, "data-hull": hullPts, opacity: opacity < 1 ? opacity : undefined }, parts);
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
handle.it = it;
|
|
329
|
+
// A point on the surface facing the viewer, halfway along the body: where a leader lands.
|
|
330
|
+
handle.anchor = (f = 0.5, side = 0) => {
|
|
331
|
+
const sA = prof[0][0] + (prof.at(-1)[0] - prof[0][0]) * f;
|
|
332
|
+
const rMax = Math.max(...prof.map((p) => p[1]));
|
|
333
|
+
let best = null;
|
|
334
|
+
for (let i = 0; i < prof.length - 1; i++) {
|
|
335
|
+
const [s0, r0] = prof[i];
|
|
336
|
+
const [s1, r1] = prof[i + 1];
|
|
337
|
+
if (sA >= Math.min(s0, s1) && sA <= Math.max(s0, s1) && s1 !== s0) best = r0 + ((r1 - r0) * (sA - s0)) / (s1 - s0);
|
|
338
|
+
}
|
|
339
|
+
const r = best ?? rMax;
|
|
340
|
+
return ringPoint(F, sA, r * 0.92, facing + side * RAD);
|
|
341
|
+
};
|
|
342
|
+
return handle;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// A prism: any floor outline (counter-clockwise points) extruded from z to z + h.
|
|
346
|
+
function prism({ name, ring, z = 0, h, tone = "mid", bias = 0, layer = 0, outline = true, into = null, group = null }) {
|
|
347
|
+
const top = ring.map(([x, y]) => [x, y, z + h]);
|
|
348
|
+
const bottom = ring.map(([x, y]) => [x, y, z]);
|
|
349
|
+
const cx = ring.reduce((s, p) => s + p[0], 0) / ring.length;
|
|
350
|
+
const cy = ring.reduce((s, p) => s + p[1], 0) / ring.length;
|
|
351
|
+
const center = [cx, cy, z + h / 2];
|
|
352
|
+
const it = item({
|
|
353
|
+
name, center, bias, layer, group, container: into?.it ?? null, points: [...top, ...bottom],
|
|
354
|
+
draw: () => {
|
|
355
|
+
const screen = [...top, ...bottom].map(project);
|
|
356
|
+
const outlinePath = pathOf(hull(screen), true);
|
|
357
|
+
const faces = [];
|
|
358
|
+
const count = ring.length;
|
|
359
|
+
const area = ring.reduce((s, p, i) => s + p[0] * ring[(i + 1) % count][1] - ring[(i + 1) % count][0] * p[1], 0);
|
|
360
|
+
for (let i = 0; i < count; i++) {
|
|
361
|
+
const a = ring[i];
|
|
362
|
+
const b = ring[(i + 1) % count];
|
|
363
|
+
const edge = [b[0] - a[0], b[1] - a[1]];
|
|
364
|
+
const nrm = area > 0 ? [edge[1], -edge[0], 0] : [-edge[1], edge[0], 0];
|
|
365
|
+
if (dot(nrm, V) <= 1e-9) continue;
|
|
366
|
+
const color = shade(nrm, tone);
|
|
367
|
+
faces.push({ d: pathOf([bottom[i], bottom[(i + 1) % count], top[(i + 1) % count], top[i]].map(project), true), color, key: depth(add(add(bottom[i], top[(i + 1) % count]), [0, 0, 0])) });
|
|
368
|
+
}
|
|
369
|
+
faces.sort((p, q) => p.key - q.key);
|
|
370
|
+
const topColor = shade([0, 0, 1], tone);
|
|
371
|
+
return el("g", { "data-part": name, "data-hull": hull(screen).map(([x, y]) => `${n(x)},${n(y)}`).join(" ") },
|
|
372
|
+
el("path", { d: outlinePath, fill: ISO.paper, stroke: "none" }),
|
|
373
|
+
faces.map((f) => el("path", { d: f.d, fill: f.color, stroke: f.color, "stroke-width": 0.09 })),
|
|
374
|
+
el("path", { d: pathOf(top.map(project), true), fill: topColor, stroke: ISO.mid, "stroke-width": 0.12, "stroke-linejoin": "round" }),
|
|
375
|
+
outline ? el("path", { d: outlinePath, fill: "none", stroke: ISO.hi, "stroke-width": 0.2, "stroke-linejoin": "round", "data-outline": "1" }) : "");
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
return { name, it, center, top: (x, y) => [x, y, z + h], anchor: () => [cx, cy, z + h] };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const roundedRing = ({ x, y, w, d, r = 0 }, steps = 6) => {
|
|
382
|
+
const rr = Math.min(r, w / 2, d / 2);
|
|
383
|
+
if (rr <= 0) return [[x, y], [x + w, y], [x + w, y + d], [x, y + d]];
|
|
384
|
+
const out = [];
|
|
385
|
+
const corners = [[x + w - rr, y + rr, -90], [x + w - rr, y + d - rr, 0], [x + rr, y + d - rr, 90], [x + rr, y + rr, 180]];
|
|
386
|
+
for (const [cx, cy, a0] of corners) for (let i = 0; i <= steps; i++) { const a = (a0 + (90 * i) / steps) * RAD; out.push([cx + rr * Math.cos(a), cy + rr * Math.sin(a)]); }
|
|
387
|
+
return out;
|
|
388
|
+
};
|
|
389
|
+
const box = ({ plan, z = 0, h, ...rest }) => prism({ ring: roundedRing(plan), z, h, ...rest });
|
|
390
|
+
|
|
391
|
+
// A pipe or cable: a filleted 3D route drawn as depth-sorted chunks, each a ribbon with a
|
|
392
|
+
// shine band toward the light and a shade band away from it.
|
|
393
|
+
// `flow` draws a dashed stripe along the pipe whose dashes march with `flow.offset` (mm along
|
|
394
|
+
// the screen route), continuous across chunks: coolant moving through a coil, gas in a line.
|
|
395
|
+
function pipe({ name, points, r, tone = "mid", bend = null, bias = 0, caps = [true, true], into = null, group = null, flow = null }) {
|
|
396
|
+
const route = fillet(points, bend ?? Math.max(r * 2.6, 3));
|
|
397
|
+
const chunks = [];
|
|
398
|
+
for (let i = 0; i < route.length - 1; i++) chunks.push([route[i], route[i + 1]]);
|
|
399
|
+
const handles = [];
|
|
400
|
+
chunks.forEach(([a, b], index) => {
|
|
401
|
+
const mid = mul(add(a, b), 0.5);
|
|
402
|
+
handles.push(item({
|
|
403
|
+
name: `${name}#${index}`, center: mid, bias, group, container: into?.it ?? null, points: [a, b],
|
|
404
|
+
draw: () => {
|
|
405
|
+
const pa = project(a);
|
|
406
|
+
const pb = project(b);
|
|
407
|
+
const dx = pb[0] - pa[0];
|
|
408
|
+
const dy = pb[1] - pa[1];
|
|
409
|
+
const l2 = Math.hypot(dx, dy);
|
|
410
|
+
const ax = unit(sub(b, a));
|
|
411
|
+
const side = l2 > 1e-6 ? [-dy / l2, dx / l2] : [1, 0];
|
|
412
|
+
const w = r * k;
|
|
413
|
+
// Which screen side faces the light: the world normal on that side of the tube.
|
|
414
|
+
const sideWorld = unit(cross(ax, V));
|
|
415
|
+
const lit = dot(sideWorld, L) * (dot(sideWorld, R) * side[0] + dot(sideWorld, D) * side[1]) >= 0 ? 1 : -1;
|
|
416
|
+
const ext = l2 > 1e-6 ? [dx / l2 * 0.06, dy / l2 * 0.06] : [0, 0];
|
|
417
|
+
const band = (o0, o1) => [
|
|
418
|
+
[pa[0] - ext[0] + side[0] * o0 * w, pa[1] - ext[1] + side[1] * o0 * w], [pb[0] + ext[0] + side[0] * o0 * w, pb[1] + ext[1] + side[1] * o0 * w],
|
|
419
|
+
[pb[0] + ext[0] + side[0] * o1 * w, pb[1] + ext[1] + side[1] * o1 * w], [pa[0] - ext[0] + side[0] * o1 * w, pa[1] - ext[1] + side[1] * o1 * w],
|
|
420
|
+
];
|
|
421
|
+
const bodyColor = paint(tone, 0.55);
|
|
422
|
+
const out = [
|
|
423
|
+
el("path", { d: pathOf(band(-1, 1), true), fill: bodyColor, stroke: bodyColor, "stroke-width": 0.09 }),
|
|
424
|
+
el("path", { d: pathOf(band(0.2 * lit, 0.62 * lit), true), fill: paint(tone, 0.95), stroke: "none" }),
|
|
425
|
+
el("path", { d: pathOf(band(-0.55 * lit, -0.98 * lit), true), fill: paint(tone, 0.12), stroke: "none" }),
|
|
426
|
+
el("path", { d: pathOf([[pa[0] + side[0] * w, pa[1] + side[1] * w], [pb[0] + side[0] * w, pb[1] + side[1] * w]]) + pathOf([[pa[0] - side[0] * w, pa[1] - side[1] * w], [pb[0] - side[0] * w, pb[1] - side[1] * w]]), fill: "none", stroke: ISO.hi, "stroke-width": 0.16, "stroke-linecap": "butt" }),
|
|
427
|
+
];
|
|
428
|
+
const capAt = (p, dir) => {
|
|
429
|
+
const F = frameAlong(p, dir);
|
|
430
|
+
const pts = Array.from({ length: 33 }, (_, m) => project(ringPoint(F, 0, r, (m / 32) * Math.PI * 2)));
|
|
431
|
+
if (dot(dir, V) > 0) return el("path", { d: pathOf(pts, true), fill: paint(tone, 0.3), stroke: ISO.hi, "stroke-width": 0.16 });
|
|
432
|
+
return el("path", { d: pathOf(pts, true), fill: bodyColor, stroke: "none" });
|
|
433
|
+
};
|
|
434
|
+
if (flow) {
|
|
435
|
+
let start = 0;
|
|
436
|
+
for (let m = 0; m < index; m++) { const p0 = project(chunks[m][0]); const p1 = project(chunks[m][1]); start += Math.hypot(p1[0] - p0[0], p1[1] - p0[1]); }
|
|
437
|
+
const [on, off] = flow.dash ?? [2.4, 2.4];
|
|
438
|
+
out.push(el("path", { d: pathOf([pa, pb]), fill: "none", stroke: flow.color ?? ISO.accent, "stroke-width": n(flow.width ?? w * 0.7), "stroke-dasharray": `${n(on)} ${n(off)}`, "stroke-dashoffset": n(start - (flow.offset ?? 0)), "stroke-linecap": "butt", "data-flow": name }));
|
|
439
|
+
}
|
|
440
|
+
if (index === 0 && caps[0]) out.push(capAt(a, mul(ax, -1)));
|
|
441
|
+
if (index === chunks.length - 1 && caps[1]) out.push(capAt(b, ax));
|
|
442
|
+
const hullPts = hull([...band(-1, 1)]).map(([x, y]) => `${n(x)},${n(y)}`).join(" ");
|
|
443
|
+
return el("g", { "data-part": name, "data-hull": hullPts }, out);
|
|
444
|
+
},
|
|
445
|
+
}));
|
|
446
|
+
});
|
|
447
|
+
return { name, route, at: (f) => pointOnRoute(route, f), anchor: (f = 0.5) => pointOnRoute(route, f), chunks: handles };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function sphere({ name, center, r, tone = "mid", bias = 0, into = null, group = null }) {
|
|
451
|
+
const profile = [];
|
|
452
|
+
for (let i = 0; i <= 16; i++) { const t = (i / 16) * Math.PI; profile.push([-r * Math.cos(t), r * Math.sin(t), i > 0 && i < 16 ? 1 : 0]); }
|
|
453
|
+
return lathe({ name, origin: center, axis: "z", profile, tone, bias, into, group });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function dots({ name, points, r = 0.5, color = ISO.mid, bias = 0.5, into = null, group = null }) {
|
|
457
|
+
const center = points.reduce((s, p) => add(s, mul(p, 1 / points.length)), [0, 0, 0]);
|
|
458
|
+
return item({ name, center, bias, group, container: into?.it ?? null, points, draw: () => el("g", { "data-detail": name }, points.map((p) => { const [x, y] = project(p); return el("circle", { cx: x, cy: y, r: r, fill: color, stroke: "none" }); })) });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// `flow: { dash: [on, off], offset, start }` in world mm marches dashes along the line: give
|
|
462
|
+
// collinear pieces of one run the same offset and their own start so the dashes line up.
|
|
463
|
+
function polyline({ name, points, color = ISO.mid, width = 0.12, bias = 0.3, dash = null, into = null, group = null, flow = null }) {
|
|
464
|
+
const center = points.reduce((s, p) => add(s, mul(p, 1 / points.length)), [0, 0, 0]);
|
|
465
|
+
return item({ name, center, bias, group, container: into?.it ?? null, points, draw: () => {
|
|
466
|
+
const screen = points.map(project);
|
|
467
|
+
let flowAttrs = {};
|
|
468
|
+
if (flow) {
|
|
469
|
+
const world = points.slice(1).reduce((sum, p, i) => sum + len(sub(p, points[i])), 0);
|
|
470
|
+
const drawn = screen.slice(1).reduce((sum, p, i) => sum + Math.hypot(p[0] - screen[i][0], p[1] - screen[i][1]), 0);
|
|
471
|
+
const f = world > 0 ? drawn / world : 0;
|
|
472
|
+
const [on, off] = flow.dash ?? [8, 8];
|
|
473
|
+
flowAttrs = { "stroke-dasharray": `${n(on * f)} ${n(off * f)}`, "stroke-dashoffset": n(((flow.start ?? 0) - (flow.offset ?? 0)) * f), "stroke-linecap": "butt", "data-flow": name };
|
|
474
|
+
}
|
|
475
|
+
return el("path", { d: pathOf(screen), fill: "none", stroke: color, "stroke-width": width, "stroke-dasharray": dash, "stroke-linecap": "round", "stroke-linejoin": "round", "data-detail": name, ...flowAttrs });
|
|
476
|
+
} });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Points the fit keeps in frame although nothing is drawn there (where an exploded or moving
|
|
480
|
+
// part goes), so every frame of an animation and every state of a page shares one scale.
|
|
481
|
+
function extent(points) {
|
|
482
|
+
return item({ name: "extent", center: points[0], points, layer: -3, draw: () => "" });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// A visible arc of bolt heads around a flange face.
|
|
486
|
+
function boltCircle({ name, frame, s, radius, count = 8, r = 0.45, into = null, group = null }) {
|
|
487
|
+
const pts = [];
|
|
488
|
+
for (let i = 0; i < count; i++) {
|
|
489
|
+
const t = (i / count) * Math.PI * 2;
|
|
490
|
+
const p = ringPoint(frame, s, radius, t);
|
|
491
|
+
pts.push(p);
|
|
492
|
+
}
|
|
493
|
+
const faceToward = dot(frame.a, V);
|
|
494
|
+
const visible = Math.abs(faceToward) > 0.12 ? pts : pts.filter((p) => dot(sub(p, frame.o), V) > 0);
|
|
495
|
+
return dots({ name, points: visible, r, color: ISO.mid, bias: 1, into, group });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function halo({ plan, z = 0 }) {
|
|
499
|
+
const ring = roundedRing({ x: plan.x - plan.w * 0.04, y: plan.y - plan.d * 0.04, w: plan.w * 1.08, d: plan.d * 1.08, r: plan.r ?? 0 });
|
|
500
|
+
return item({ name: "halo", center: [plan.x + plan.w / 2, plan.y + plan.d / 2, z], layer: -2, points: ring.map(([x, y]) => [x, y, z]), draw: () => el("path", { d: pathOf(ring.map(([x, y]) => project([x, y, z])), true), fill: ISO.halo, "fill-opacity": ISO.haloOpacity / 2.4, stroke: "none", "data-halo": "1" }) });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// A callout names a part: give the part handle (its anchor is used) or a world point on it.
|
|
504
|
+
function callout({ part = null, at = null, text, side = "auto", number = null }) {
|
|
505
|
+
const point = at ?? part?.anchor?.();
|
|
506
|
+
if (!point) throw new Error(`callout "${text}": give a part handle or an "at" point on the part`);
|
|
507
|
+
callouts.push({ at: point, text, side, number, part: part?.name ?? null });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Fits the art and its callout columns into boxMm. Callout sides are settled first (by where
|
|
511
|
+
// each anchor falls across the art, balanced when one side would take nearly all of them), then
|
|
512
|
+
// only the sides in use get a column as wide as their longest label plus the leader's run.
|
|
513
|
+
function fit(boxMm, { pad = 2, labelColumn = null, size = 2.47 } = {}) {
|
|
514
|
+
const all = [];
|
|
515
|
+
const walk = (list) => { for (const it of list) { all.push(...it.points); walk(it.children); } };
|
|
516
|
+
walk(items);
|
|
517
|
+
const flat = all.map((p) => [dot(p, R), dot(p, D)]);
|
|
518
|
+
const xs = flat.map((p) => p[0]);
|
|
519
|
+
const ys = flat.map((p) => p[1]);
|
|
520
|
+
const midX = (Math.max(...xs) + Math.min(...xs)) / 2;
|
|
521
|
+
const anchorsX = callouts.map((c) => dot(c.at, R));
|
|
522
|
+
const natural = callouts.map((c, i) => (c.side === "auto" ? (anchorsX[i] < midX ? "left" : "right") : c.side));
|
|
523
|
+
const byX = callouts.map((_, i) => i).filter((i) => callouts[i].side === "auto").sort((a, b) => anchorsX[a] - anchorsX[b]);
|
|
524
|
+
const balanced = [...natural];
|
|
525
|
+
byX.forEach((i, rank) => { balanced[i] = rank < Math.floor(byX.length / 2) ? "left" : "right"; });
|
|
526
|
+
const allTo = (side) => callouts.map((c) => (c.side === "auto" ? side : c.side));
|
|
527
|
+
const [x0, y0, x1, y1] = boxMm;
|
|
528
|
+
const spanX = Math.max(...xs) - Math.min(...xs);
|
|
529
|
+
const spanY = Math.max(...ys) - Math.min(...ys);
|
|
530
|
+
const widthOf = (sides, side) => {
|
|
531
|
+
const list = callouts.filter((_, i) => sides[i] === side);
|
|
532
|
+
if (!list.length) return 0;
|
|
533
|
+
if (labelColumn !== null) return labelColumn;
|
|
534
|
+
return Math.max(...list.map((c) => textBox(0, 0, c.number !== null ? `${c.number} ${c.text}` : c.text, size)[2])) + 7;
|
|
535
|
+
};
|
|
536
|
+
// Whichever assignment leaves the art largest wins; rows per side must also fit the height.
|
|
537
|
+
let best = null;
|
|
538
|
+
for (const sides of [natural, balanced, allTo("right"), allTo("left")]) {
|
|
539
|
+
const left = widthOf(sides, "left");
|
|
540
|
+
const right = widthOf(sides, "right");
|
|
541
|
+
const width = x1 - x0 - 2 * pad - left - right;
|
|
542
|
+
const rows = Math.max(...["left", "right"].map((side) => sides.filter((v) => v === side).length));
|
|
543
|
+
if (width <= 0 || rows * (size * 1.25 + 1.4) > y1 - y0) continue;
|
|
544
|
+
const scale = Math.min(width / spanX, (y1 - y0 - 2 * pad) / spanY);
|
|
545
|
+
if (!best || scale > best.scale + 1e-9) best = { sides, left, right, width, scale };
|
|
546
|
+
}
|
|
547
|
+
if (!best) throw new Error("iso fit: the callouts do not fit beside the art; widen the box or shorten the labels");
|
|
548
|
+
callouts.forEach((c, i) => { c.resolved = best.sides[i]; });
|
|
549
|
+
const { left, right, width } = best;
|
|
550
|
+
const height = y1 - y0 - 2 * pad;
|
|
551
|
+
k = best.scale;
|
|
552
|
+
origin = [
|
|
553
|
+
x0 + pad + left + width / 2 - ((Math.max(...xs) + Math.min(...xs)) / 2) * k,
|
|
554
|
+
y0 + pad + height / 2 - ((Math.max(...ys) + Math.min(...ys)) / 2) * k,
|
|
555
|
+
];
|
|
556
|
+
return { scale: k, artBox: [x0 + pad + left, y0 + pad, x1 - pad - right, y1 - pad] };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function renderCallouts({ box: frameBox, size = 2.4, gap = 2.5 }) {
|
|
560
|
+
if (!callouts.length) return "";
|
|
561
|
+
const all = [];
|
|
562
|
+
const walk = (list) => { for (const it of list) { all.push(...it.points.map(project)); walk(it.children); } };
|
|
563
|
+
walk(items);
|
|
564
|
+
const artLeft = Math.min(...all.map((p) => p[0]));
|
|
565
|
+
const artRight = Math.max(...all.map((p) => p[0]));
|
|
566
|
+
const mid = (artLeft + artRight) / 2;
|
|
567
|
+
const placed = callouts.map((c, index) => {
|
|
568
|
+
const anchor = project(c.at);
|
|
569
|
+
const side = c.resolved ?? (c.side === "auto" ? (anchor[0] < mid ? "left" : "right") : c.side);
|
|
570
|
+
return { ...c, index, anchor, side };
|
|
571
|
+
});
|
|
572
|
+
const out = [];
|
|
573
|
+
for (const side of ["left", "right"]) {
|
|
574
|
+
const col = placed.filter((c) => c.side === side).sort((a, b) => a.anchor[1] - b.anchor[1]);
|
|
575
|
+
const line = size * 1.25 + 1.4;
|
|
576
|
+
let y = frameBox[1] + size;
|
|
577
|
+
const ys = col.map((c) => { y = Math.max(y, c.anchor[1] + size * 0.35); const v = y; y += line; return v; });
|
|
578
|
+
const overflow = ys.length ? ys.at(-1) - (frameBox[3] - 0.6) : 0;
|
|
579
|
+
if (overflow > 0) for (let i = ys.length - 1; i >= 0; i--) { const limit = i === ys.length - 1 ? frameBox[3] - 0.6 : ys[i + 1] - line; ys[i] = Math.min(ys[i], limit); }
|
|
580
|
+
// Order the labels so no two leaders cross or run within LEADER_GAP of each other (or of
|
|
581
|
+
// another leader's dot): swap slots while that lowers the cost, then open a blank row
|
|
582
|
+
// above a crowded leader to change its angle when the column has room.
|
|
583
|
+
const order = col.map((_, i) => i);
|
|
584
|
+
// Same elbow x as the drawn leader below.
|
|
585
|
+
const elbowAt = side === "left" ? Math.min(artLeft - gap * 2 + 1.2, artLeft - gap) : Math.max(artRight + gap * 2 - 1.2, artRight + gap);
|
|
586
|
+
const leaderOf = (ci, slot) => [[elbowAt, ys[slot] - size * 0.35], col[ci].anchor];
|
|
587
|
+
const pairCost = (p, q) => {
|
|
588
|
+
if (crosses(p, q)) return 1000;
|
|
589
|
+
const d = Math.min(segDist(p[0], q[0], q[1]), segDist(p[1], q[0], q[1]), segDist(q[0], p[0], p[1]), segDist(q[1], p[0], p[1]));
|
|
590
|
+
return d < LEADER_GAP ? (LEADER_GAP - d) * 100 : 0;
|
|
591
|
+
};
|
|
592
|
+
// Cost is [violations, leader length]: clearing a crossing or a crowding always wins, and
|
|
593
|
+
// among equally clean orders the one with the shortest, shallowest leaders does.
|
|
594
|
+
const cost = () => {
|
|
595
|
+
let bad = 0;
|
|
596
|
+
let length = 0;
|
|
597
|
+
for (let i = 0; i < order.length; i++) {
|
|
598
|
+
const li = leaderOf(order[i], i);
|
|
599
|
+
length += Math.hypot(li[1][0] - li[0][0], li[1][1] - li[0][1]);
|
|
600
|
+
for (let j = i + 1; j < order.length; j++) bad += pairCost(li, leaderOf(order[j], j));
|
|
601
|
+
}
|
|
602
|
+
return [bad, length];
|
|
603
|
+
};
|
|
604
|
+
const better = (a, b) => a[0] < b[0] - 1e-9 || (Math.abs(a[0] - b[0]) <= 1e-9 && a[1] < b[1] - 1e-6);
|
|
605
|
+
let best = cost();
|
|
606
|
+
for (let pass = 0; pass < col.length * col.length; pass++) {
|
|
607
|
+
let improved = false;
|
|
608
|
+
for (let i = 0; i < order.length; i++) for (let j = i + 1; j < order.length; j++) {
|
|
609
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
610
|
+
const c = cost();
|
|
611
|
+
if (better(c, best)) { best = c; improved = true; } else [order[i], order[j]] = [order[j], order[i]];
|
|
612
|
+
}
|
|
613
|
+
if (!improved) break;
|
|
614
|
+
}
|
|
615
|
+
// Still crowded: open one blank row, either pushing the rows below it down or the rows
|
|
616
|
+
// above it up, wherever the column has room, and keep the move that clears the most.
|
|
617
|
+
const top = frameBox[1] + size;
|
|
618
|
+
const bottom = frameBox[3] - 0.6;
|
|
619
|
+
for (let tries = 0; tries < col.length && best[0] > 0; tries++) {
|
|
620
|
+
let chosen = null;
|
|
621
|
+
for (let k = 0; k <= ys.length; k++) {
|
|
622
|
+
for (const dir of [1, -1]) {
|
|
623
|
+
const moved = ys.map((y, m) => (dir > 0 ? (m >= k ? y + line : y) : (m < k ? y - line : y)));
|
|
624
|
+
if (moved.length && (moved.at(-1) > bottom || moved[0] < top)) continue;
|
|
625
|
+
const saved = [...ys];
|
|
626
|
+
ys.splice(0, ys.length, ...moved);
|
|
627
|
+
const c = cost();
|
|
628
|
+
ys.splice(0, ys.length, ...saved);
|
|
629
|
+
if (better(c, best) && c[0] < best[0] && (!chosen || better(c, chosen.c))) chosen = { moved, c };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (!chosen) break;
|
|
633
|
+
ys.splice(0, ys.length, ...chosen.moved);
|
|
634
|
+
best = chosen.c;
|
|
635
|
+
}
|
|
636
|
+
if (best[0] > 0) warnings.push(`callouts on the ${side}: some leaders still cross or run closer than ${LEADER_GAP} mm; move a callout dot to a clearer spot on its part or send the label to the other side`);
|
|
637
|
+
const slotted = order.map((ci) => col[ci]);
|
|
638
|
+
slotted.forEach((c, i) => {
|
|
639
|
+
const label = c.number !== null ? `${c.number} ${c.text}` : c.text;
|
|
640
|
+
const ty = ys[i];
|
|
641
|
+
const tx = side === "left" ? artLeft - gap * 2 : artRight + gap * 2;
|
|
642
|
+
const anchorText = side === "left" ? "end" : "start";
|
|
643
|
+
const bx = textBox(tx, ty, label, size, anchorText);
|
|
644
|
+
const elbowX = side === "left" ? Math.min(bx[2] + 1.2, artLeft - gap) : Math.max(bx[0] - 1.2, artRight + gap);
|
|
645
|
+
const elbowY = ty - size * 0.35;
|
|
646
|
+
const id = `C${c.index + 1}`;
|
|
647
|
+
out.push(el("path", { d: pathOf([[side === "left" ? bx[2] + 0.6 : bx[0] - 0.6, elbowY], [elbowX, elbowY], c.anchor]), fill: "none", stroke: ISO.hi, "stroke-width": 0.14, "stroke-linecap": "round", "data-leader": id, "data-target": c.part ?? undefined }));
|
|
648
|
+
out.push(el("circle", { cx: c.anchor[0], cy: c.anchor[1], r: 0.55, fill: ISO.hi, stroke: ISO.paper, "stroke-width": 0.12, "data-leader-dot": id }));
|
|
649
|
+
out.push(el("text", { x: tx, y: ty, "font-family": fontFor(label), "font-size": size, "text-anchor": anchorText, fill: ISO.hi, "data-label": "callout", "data-for-leader": id }, esc(label)));
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return el("g", { "data-callouts": "1" }, out);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function render() {
|
|
656
|
+
const draw = (list) => [...list].sort((a, b) => a.layer - b.layer || a.key - b.key).map((it) => {
|
|
657
|
+
const svg = it.draw(draw(it.children));
|
|
658
|
+
return it.group ? el("g", { "data-group": it.group }, svg) : svg;
|
|
659
|
+
}).join("");
|
|
660
|
+
return draw(items);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// The screen offset (mm) a world move vector makes at the fitted scale: what an exploded view
|
|
664
|
+
// translates an assembly by.
|
|
665
|
+
const screenVector = (v) => [dot(v, R) * k, dot(v, D) * k];
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
project, depth, V, R, D, L, items, warnings, screenVector,
|
|
669
|
+
lathe, prism, box, pipe, sphere, dots, polyline, extent, boltCircle, halo, callout, fit, render, renderCallouts,
|
|
670
|
+
roundedRing,
|
|
671
|
+
get scale() { return k; },
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Rounds every corner of a 3D polyline with an arc of radius `radius`, clamped to the room
|
|
676
|
+
// the legs leave, so pipes bend instead of kinking.
|
|
677
|
+
export function fillet(points, radius, perArc = 6) {
|
|
678
|
+
if (points.length < 3) return points;
|
|
679
|
+
const out = [points[0]];
|
|
680
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
681
|
+
const p = points[i];
|
|
682
|
+
const a = unit(sub(points[i - 1], p));
|
|
683
|
+
const b = unit(sub(points[i + 1], p));
|
|
684
|
+
const cosT = Math.max(-1, Math.min(1, dot(a, b)));
|
|
685
|
+
const theta = Math.acos(cosT);
|
|
686
|
+
if (theta > Math.PI - 1e-3) { out.push(p); continue; }
|
|
687
|
+
const room = Math.min(len(sub(points[i - 1], p)), len(sub(points[i + 1], p))) * 0.48;
|
|
688
|
+
const t = Math.min(radius / Math.tan(theta / 2), room);
|
|
689
|
+
const r = t * Math.tan(theta / 2);
|
|
690
|
+
const start = add(p, mul(a, t));
|
|
691
|
+
const end = add(p, mul(b, t));
|
|
692
|
+
const bis = unit(add(a, b));
|
|
693
|
+
const centre = add(p, mul(bis, r / Math.sin(theta / 2)));
|
|
694
|
+
const u = unit(sub(start, centre));
|
|
695
|
+
const w = unit(sub(end, centre));
|
|
696
|
+
const sweep = Math.acos(Math.max(-1, Math.min(1, dot(u, w))));
|
|
697
|
+
const nrm = unit(cross(u, w));
|
|
698
|
+
const vv = cross(nrm, u);
|
|
699
|
+
for (let m = 0; m <= perArc; m++) {
|
|
700
|
+
const ang = (sweep * m) / perArc;
|
|
701
|
+
out.push(add(centre, mul(add(mul(u, Math.cos(ang)), mul(vv, Math.sin(ang))), r)));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
out.push(points.at(-1));
|
|
705
|
+
return out;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function pointOnRoute(route, f) {
|
|
709
|
+
const lens = route.slice(1).map((p, i) => len(sub(p, route[i])));
|
|
710
|
+
const total = lens.reduce((s, v) => s + v, 0);
|
|
711
|
+
let left = f * total;
|
|
712
|
+
for (let i = 0; i < lens.length; i++) {
|
|
713
|
+
if (left <= lens[i]) return add(route[i], mul(sub(route[i + 1], route[i]), lens[i] ? left / lens[i] : 0));
|
|
714
|
+
left -= lens[i];
|
|
715
|
+
}
|
|
716
|
+
return route.at(-1);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export { FONT_LATIN };
|