@reekon-tools/boldr-cad 1.1.0 → 1.3.0
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/README.md +30 -1
- package/dist/app/state.d.ts +9 -0
- package/dist/app/state.js +5 -0
- package/dist/app/state.js.map +1 -1
- package/dist/editor/actions.js +12 -0
- package/dist/editor/actions.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/react/host.d.ts +22 -0
- package/dist/react/host.js +33 -1
- package/dist/react/host.js.map +1 -1
- package/dist/render/draw.js +24 -0
- package/dist/render/draw.js.map +1 -1
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/survey.d.ts +32 -0
- package/dist/tools/survey.js +286 -0
- package/dist/tools/survey.js.map +1 -0
- package/package.json +1 -1
- package/src/app/state.ts +5 -0
- package/src/editor/actions.ts +13 -2
- package/src/index.ts +1 -1
- package/src/react/host.ts +33 -1
- package/src/render/draw.ts +17 -0
- package/src/tools/index.ts +4 -0
- package/src/tools/survey.ts +260 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { afterEdit, analyze, tryAddConstraint } from '../app/edit';
|
|
2
|
+
import { ed } from '../app/state';
|
|
3
|
+
import { measure } from '../model/constraints';
|
|
4
|
+
import { hyp } from '../model/geom';
|
|
5
|
+
import { pushHistory } from '../model/history';
|
|
6
|
+
import { maybeRoom, roomGeom } from '../model/rooms';
|
|
7
|
+
import { onSessionReset, sessionState } from '../model/session';
|
|
8
|
+
import { addLine, addPoint, gc, lineBetween, pinnedPoints } from '../model/sketch';
|
|
9
|
+
import { page, SK, store } from '../model/store';
|
|
10
|
+
import type { Constraint, XY } from '../model/types';
|
|
11
|
+
import { TH } from '../render/theme';
|
|
12
|
+
import { requestDraw, requestOverlayDraw, S, V } from '../render/view';
|
|
13
|
+
import { fmtArea, fmtLen } from '../units';
|
|
14
|
+
import { elevOpen } from '../editor/elevation';
|
|
15
|
+
import { keypadOpen } from '../editor/keypad';
|
|
16
|
+
import type { KeyIn } from '../editor/keys';
|
|
17
|
+
import { drawsWalls, setMode } from '../editor/mode';
|
|
18
|
+
import { toast, updateCtx, updateHint } from '../editor/ports';
|
|
19
|
+
import { close3d } from '../editor/view3d';
|
|
20
|
+
import { is3d } from '../editor/view3dState';
|
|
21
|
+
import { anchorMark, lengthPreview, setTool, type Tool } from './registry';
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
The templator: points measured by a device in the host app (BOLDR's T1R) build the drawing directly. Each point joins
|
|
25
|
+
the last with a line (or a wall, with the Walls toggle on), and the drawing holds what was measured: a driving
|
|
26
|
+
length on every edge, and a driving angle at every corner. That fixes the shape's FORM — a later drag moves it
|
|
27
|
+
whole, it doesn't flex it — while leaving it free to be moved and turned into place. On a closed shape the last
|
|
28
|
+
edge and the corners either side of it are already set by the rest, so they come out as reference dimensions
|
|
29
|
+
(grey, in parentheses) rather than being refused: rule 3, and the closing edge's length still shows.
|
|
30
|
+
|
|
31
|
+
Where it lands: the device measures from itself, not from the page. The first point taken on a page puts its frame
|
|
32
|
+
there — that point at the middle of the screen — and every later point keeps its true place relative to it, for
|
|
33
|
+
the rest of the session. Kept per page and not per run on purpose: a cutout taken after its outline, from the same
|
|
34
|
+
setup, has to land where it really is relative to the outline, or templating it was pointless.
|
|
35
|
+
|
|
36
|
+
Points come in on the plan's own axes — x to the right, y up (render/view.ts's S) — and are placed as given. Which
|
|
37
|
+
way a device's angle turns is the host's business, not the editor's: the host turns its device's frame into this
|
|
38
|
+
one, and that's where a mirror gets in or doesn't. BOLDR's T1R turns clockwise, so the app negates y
|
|
39
|
+
(templatorPlanInches); passing its x = r·cos θ, y = r·sin θ straight in drew every template mirrored (seen on a device,
|
|
40
|
+
2026-10-28).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** How close a point has to land to the first one to close the shape: about 50 mm, inside the device's own error. */
|
|
44
|
+
export const SURVEY_CLOSE_IN = 2;
|
|
45
|
+
/** A point this close to the last one is the same point taken twice (a double press), not a new corner. */
|
|
46
|
+
export const SURVEY_SAME_IN = 1 / 16;
|
|
47
|
+
|
|
48
|
+
interface SurveyState {
|
|
49
|
+
/** the run's points, in order */
|
|
50
|
+
chain: string[];
|
|
51
|
+
/** each point's host record (surveyPoint's `capture`), kept with the dimensions it sets */
|
|
52
|
+
captures: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SurveyTool extends Tool {
|
|
56
|
+
st: SurveyState;
|
|
57
|
+
closeShape(): void;
|
|
58
|
+
finish(): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Each page's frame for the session: what's added to a device point to place it on the plan. */
|
|
62
|
+
const frames = sessionState(() => ({ byPage: {} as Record<string, { dx: number; dy: number }> }));
|
|
63
|
+
|
|
64
|
+
const fresh = (): SurveyState => ({ chain: [], captures: {} });
|
|
65
|
+
const withCapture = (capture: unknown) => (capture !== undefined ? { capture } : {});
|
|
66
|
+
|
|
67
|
+
/** A driving length on line `lid`, as the two points it joins are now (so nothing moves). */
|
|
68
|
+
function holdLength(lid: string, a: string, b: string, capture: unknown): void {
|
|
69
|
+
const sk = SK(), pa = sk.points[a], pb = sk.points[b];
|
|
70
|
+
if (!pa || !pb) return;
|
|
71
|
+
tryAddConstraint({ type: 'dist', a, b, kind: 'al', value: hyp(pb.x - pa.x, pb.y - pa.y), line: lid, driving: true, off: 0, ...withCapture(capture) }, { silent: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A corner this close to dead straight (or folded right back) is held as Collinear, not as an angle: the angle's
|
|
76
|
+
* equation turns on |cross|, whose slope vanishes there, so the solver reads a 180° angle as adding nothing (a
|
|
77
|
+
* reference dimension) and the corner is left as a hinge. Collinear says the same thing and holds it. 1e-4° is far
|
|
78
|
+
* outside where that happens (about 2e-6°) and far inside any real capture's spread, so a measured corner is never
|
|
79
|
+
* squared off by it — over a 30" edge it's a micrometre.
|
|
80
|
+
*/
|
|
81
|
+
const STRAIGHT_DEG = 1e-4;
|
|
82
|
+
|
|
83
|
+
/** A driving angle at the corner where `l1` and `l2` meet, as it is now (Collinear, if it's straight). */
|
|
84
|
+
function holdAngle(l1: string | undefined, l2: string | undefined, capture: unknown): void {
|
|
85
|
+
if (!l1 || !l2 || l1 === l2) return;
|
|
86
|
+
const c: Constraint = { type: 'angle', l1, l2, value: 0, driving: true };
|
|
87
|
+
c.value = measure(SK(), c);
|
|
88
|
+
if (c.value < STRAIGHT_DEG || c.value > 180 - STRAIGHT_DEG) { tryAddConstraint({ type: 'col', l1, l2 }, { silent: true }); return; }
|
|
89
|
+
tryAddConstraint({ ...c, ...withCapture(capture) }, { silent: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const surveyTool: SurveyTool = {
|
|
93
|
+
st: fresh(),
|
|
94
|
+
/** Picked up (by a point arriving with another tool in hand): a new run. */
|
|
95
|
+
enter() { this.st = fresh(); },
|
|
96
|
+
/**
|
|
97
|
+
* Put down by cancelTool. Deliberately NOT the end of the run: undo cancels the tool in hand before it steps back,
|
|
98
|
+
* and ending the run there would leave a gap after every bad point undone. The run ends on Finish, Close shape,
|
|
99
|
+
* Esc or Enter (key, below) — which also put the templator down — or when another tool is picked up; either way the
|
|
100
|
+
* next point starts a new run (enter).
|
|
101
|
+
*/
|
|
102
|
+
reset() { ed.preview = null; },
|
|
103
|
+
busy() { return false; },
|
|
104
|
+
/** Esc and Enter finish the run here, before the generic Esc (which can't tell itself from undo's cancel). */
|
|
105
|
+
key(e: KeyIn) {
|
|
106
|
+
if ((e.key === 'Escape' || e.key === 'Enter') && this.st.chain.length) { this.finish(); return true; }
|
|
107
|
+
return false;
|
|
108
|
+
},
|
|
109
|
+
/**
|
|
110
|
+
* End the run: the shape stays as drawn, and the templator is put down (back to Select). Left in hand it would
|
|
111
|
+
* swallow every tap — it has no pointer handling, its points come from the device — so the shape just made couldn't
|
|
112
|
+
* be selected, and a phone's bar, which drops the "Templator" label, wouldn't even say why. The next point picks it
|
|
113
|
+
* up again, in the same frame. A run of one point leaves nothing (a point on its own isn't a shape).
|
|
114
|
+
*/
|
|
115
|
+
finish() {
|
|
116
|
+
const first = this.st.chain[0];
|
|
117
|
+
if (first) pinnedPoints.delete(first);
|
|
118
|
+
this.st = fresh();
|
|
119
|
+
ed.preview = null;
|
|
120
|
+
if (store.project) { gc(SK()); analyze(); }
|
|
121
|
+
if (ed.tool === 'survey') setTool('select');
|
|
122
|
+
updateCtx(); updateHint(); requestDraw();
|
|
123
|
+
},
|
|
124
|
+
/** Close the run back to its first point (the bar's Close shape, or a point taken there). */
|
|
125
|
+
closeShape() {
|
|
126
|
+
if (liveChain().length < 3) return;
|
|
127
|
+
pushHistory();
|
|
128
|
+
closeRun(undefined);
|
|
129
|
+
afterEdit();
|
|
130
|
+
},
|
|
131
|
+
hint() {
|
|
132
|
+
return this.st.chain.length
|
|
133
|
+
? 'Take the next point with your templator. Take one back at the first point to close the shape.'
|
|
134
|
+
: 'Take points with your templator: each one joins the last.';
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** The run's points that are still in the drawing (an undo since can have taken the last few back). */
|
|
139
|
+
function liveChain(): string[] {
|
|
140
|
+
const sk = SK(), st = surveyTool.st;
|
|
141
|
+
const keep = st.chain.filter(id => sk.points[id]);
|
|
142
|
+
if (keep.length !== st.chain.length) st.chain = keep;
|
|
143
|
+
return keep;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The run's first and last points, marked — where the next point joins, and where one would close the shape — and,
|
|
148
|
+
* while the device streams its live point (setSurveyCursor), the edge the next point would make: dashed from the last
|
|
149
|
+
* point to it, with its length, as the tool's own screen draws it. Worked out as the overlay paints, so it follows
|
|
150
|
+
* the live point (and a point undone) without being set again.
|
|
151
|
+
*/
|
|
152
|
+
function showRun(): void {
|
|
153
|
+
ed.preview = (c, T) => {
|
|
154
|
+
const sk = SK(), ch = surveyTool.st.chain.filter(id => sk.points[id]);
|
|
155
|
+
if (!ch.length) return;
|
|
156
|
+
const last = sk.points[ch[ch.length - 1]], cur = ed.surveyCursor;
|
|
157
|
+
if (cur && cur.page === page().id) {
|
|
158
|
+
const [x1, y1] = S(T, last.x, last.y), [x2, y2] = S(T, cur.x, cur.y);
|
|
159
|
+
c.save(); c.strokeStyle = TH.blue; c.lineWidth = 1.5; c.setLineDash([6, 5]);
|
|
160
|
+
c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke(); c.restore();
|
|
161
|
+
lengthPreview(c, T, last, cur);
|
|
162
|
+
}
|
|
163
|
+
for (const id of new Set([ch[0], ch[ch.length - 1]])) { const p = sk.points[id]; const [x, y] = S(T, p.x, p.y); anchorMark(c, x, y); }
|
|
164
|
+
};
|
|
165
|
+
requestOverlayDraw();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A device point on the current page's plan: the page's frame added. The page's first point or first live point,
|
|
170
|
+
* whichever comes first, sets the frame — that point at the middle of the screen — so the first point taken lands
|
|
171
|
+
* under the cursor that was showing it. (The caller has made sure no wall view is open: V() would be the wall's.)
|
|
172
|
+
*/
|
|
173
|
+
function onPlan(x: number, y: number): XY {
|
|
174
|
+
const pg = page().id;
|
|
175
|
+
if (!frames.byPage[pg]) { const v = V(); frames.byPage[pg] = { dx: v.cx - x, dy: v.cy - y }; }
|
|
176
|
+
const f = frames.byPage[pg];
|
|
177
|
+
return { x: x + f.dx, y: y + f.dy };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The device's live point, in inches on the plan's axes as takeSurveyPoint takes one, or null to hide it: a ring
|
|
182
|
+
* where its next point would land (render/draw.ts), plus the would-be edge while a run is open (showRun). Overlay
|
|
183
|
+
* only — it never touches the drawing, the undo history or React, so a host can call it many times a second.
|
|
184
|
+
* Whether it's showing afterwards: never in a wall view (its points belong to the plan) or a view-only drawing.
|
|
185
|
+
*/
|
|
186
|
+
export function setSurveyCursor(p: XY | null): boolean {
|
|
187
|
+
if (!p || ed.readOnly || elevOpen() || !store.project) {
|
|
188
|
+
if (ed.surveyCursor) { ed.surveyCursor = null; requestOverlayDraw(); }
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const at = onPlan(p.x, p.y);
|
|
192
|
+
ed.surveyCursor = { x: at.x, y: at.y, page: page().id };
|
|
193
|
+
// (an undo cancels the tool's preview: the run's would-be edge comes back with the next live point)
|
|
194
|
+
if (ed.tool === 'survey' && !ed.preview && surveyTool.st.chain.length) showRun();
|
|
195
|
+
else requestOverlayDraw();
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Close the run: the last edge back to the first point, its length, the two corners it makes, and a space. */
|
|
200
|
+
function closeRun(capture: unknown): void {
|
|
201
|
+
const sk = SK(), st = surveyTool.st, ch = st.chain;
|
|
202
|
+
const first = ch[0], last = ch[ch.length - 1];
|
|
203
|
+
const lid = addLine(sk, last, first, { kind: drawsWalls() ? 'wall' : 'line', constr: !drawsWalls() && ed.drawConstr });
|
|
204
|
+
// (already set by the rest of the shape: these come out as reference dimensions)
|
|
205
|
+
holdLength(lid, last, first, capture ?? st.captures[first]);
|
|
206
|
+
holdAngle(lineBetween(sk, ch[ch.length - 2], last), lid, st.captures[last]);
|
|
207
|
+
holdAngle(lid, lineBetween(sk, first, ch[1]), st.captures[first]);
|
|
208
|
+
const n = ch.length, rid = drawsWalls() || !ed.drawConstr ? maybeRoom(sk, ch.slice()) : null;
|
|
209
|
+
surveyTool.finish();
|
|
210
|
+
toast(rid ? sk.rooms[rid].name + ' added — ' + fmtArea(roomGeom(sk, sk.rooms[rid]).area) : 'Closed shape, ' + n + ' sides');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A point from the host's device, in inches, on the plan's axes — y up (see the note at the top). Starts the
|
|
215
|
+
* templator if it isn't in hand, and the run's frame on a page with none yet. False when the drawing can't take it
|
|
216
|
+
* now (the toast says why).
|
|
217
|
+
*/
|
|
218
|
+
export function takeSurveyPoint(x: number, y: number, capture?: unknown): boolean {
|
|
219
|
+
if (ed.readOnly) return false;
|
|
220
|
+
if (elevOpen()) { toast('Back out of the wall view to take templator points on the plan.', { warn: true }); return false; }
|
|
221
|
+
if (keypadOpen()) { toast('Close the keypad, then take the point again.', { warn: true }); return false; }
|
|
222
|
+
// (the templator works on the plan: from the 3D tab or Annotate, back to Draft)
|
|
223
|
+
if (is3d()) close3d();
|
|
224
|
+
if (ed.mode !== 'draft') setMode('draft');
|
|
225
|
+
if (ed.tool !== 'survey') setTool('survey');
|
|
226
|
+
|
|
227
|
+
const sk = SK(), st = surveyTool.st, ch = liveChain();
|
|
228
|
+
const { x: wx, y: wy } = onPlan(x, y);
|
|
229
|
+
|
|
230
|
+
const last = ch.length ? sk.points[ch[ch.length - 1]] : null;
|
|
231
|
+
if (last && hyp(wx - last.x, wy - last.y) < SURVEY_SAME_IN) { toast('Same point as the last one — move the templator, then take the next.', { warn: true }); return false; }
|
|
232
|
+
if (ch.length >= 3) {
|
|
233
|
+
const p0 = sk.points[ch[0]];
|
|
234
|
+
if (hyp(wx - p0.x, wy - p0.y) < SURVEY_CLOSE_IN) { pushHistory(); closeRun(capture); afterEdit(); return true; }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
pushHistory();
|
|
238
|
+
const id = addPoint(sk, wx, wy);
|
|
239
|
+
if (capture !== undefined) st.captures[id] = capture;
|
|
240
|
+
if (!ch.length) {
|
|
241
|
+
// (on its own until the next point, garbage collection would take it: kept until the run ends)
|
|
242
|
+
pinnedPoints.add(id);
|
|
243
|
+
} else {
|
|
244
|
+
const prev = ch[ch.length - 1];
|
|
245
|
+
const lid = addLine(sk, prev, id, { kind: drawsWalls() ? 'wall' : 'line', constr: !drawsWalls() && ed.drawConstr });
|
|
246
|
+
holdLength(lid, prev, id, capture);
|
|
247
|
+
if (ch.length >= 2) holdAngle(lineBetween(sk, ch[ch.length - 2], prev), lid, st.captures[prev]);
|
|
248
|
+
}
|
|
249
|
+
st.chain.push(id);
|
|
250
|
+
ed.sel = [];
|
|
251
|
+
afterEdit();
|
|
252
|
+
showRun();
|
|
253
|
+
updateHint();
|
|
254
|
+
const n = st.chain.length, a = sk.points[st.chain[n - 2]];
|
|
255
|
+
toast(n === 1 ? 'Templator: point 1, at the middle of the screen' : 'Point ' + n + ' · ' + fmtLen(hyp(wx - a.x, wy - a.y)));
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// a new session (another drawing): every page's frame and the run start again
|
|
260
|
+
onSessionReset(() => { surveyTool.st = fresh(); });
|