feat(viewer): wire "save 2D drawing markup into model" into the UI - #4170
feat(viewer): wire "save 2D drawing markup into model" into the UI#4170BIMvoice wants to merge 4 commits into
Conversation
Refs #4153 Adds a "Save Markup to Model" action to the 2D drawing panel (toolbar icon + narrow-layout overflow menu) that resolves the active model's spatial anchor and root representation context, then calls @ifc-lite/create's addDrawingMarkupToStore with the current drawing's measurements/areas/text/clouds. It writes into the model's edit overlay only (the same overlay ExportChangesButton already reads from via markModelsDirty) — the confirmation toast says explicitly that Export Changes is still needed to reach disk. Pressing it again sweeps the previously-saved tagged annotations first, so re-saving replaces rather than duplicates; pressing it with an empty drawing clears any previously-saved markup instead of refusing outright. Restore-on-load reads a model's tagged IfcAnnotation entities back into Drawing2DState via the existing readDrawingMarkupFromParseResult reader, reusing the symbolic-annotation parse cache (no second WASM walk). It never overwrites markup another source (a user's own drawing, or PR #4159's separate localStorage restore, not built on here) already populated for the same model this session, and is resilient to a model with no parsed source yet or no tagged annotations at all. Along the way: dataStore.entities.getObjectType only returns a value for entities the lite parser's columnar builder batch-extracted ObjectType for, which excludes IfcAnnotation (batched for GlobalId+Name only) — restore now reads ObjectType via EntityExtractor off the source bytes instead, the same convention resolve-anchor.ts uses for other root attributes. Stacks on #4167 (read side) -> #4160 (write side); this is the third layer and should merge last. No CI runs on this branch (test.yml triggers only on pull_request: branches: [main]) — all checks below were run locally. Local verification: - pnpm typecheck: 0 errors (1767 test files across 48 packages) - pnpm lint (oxlint): 0 errors, 5 pre-existing warnings unrelated - node scripts/check-module-size.mjs: OK, 0 new over 400 - node scripts/check-test-wiring.mjs: OK - node scripts/check-source-text-assertions.mjs: OK, 0 new - pnpm turbo run test --filter=@ifc-lite/viewer: 7173 pass, 0 fail, 6 pre-existing skips (7179 total, 42/42 tasks) - Mutation testing (grep/diff-confirmed applied, reverted after): breaking the write call in saveDrawingMarkupToModel -> 4/7 save tests RED; breaking ObjectType extraction in buildDrawingMarkupMetaLookup -> 3/9 restore tests RED across both new suites (7/16 combined RED on the second sweep after a context refactor)
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Claude review - no findings for
|
|
Adversarial review came back clean — two follow-ups worth recording, neither blocking. Verified by execution (built the real packages, drove save → sweep → save → 1. Double-restore can discard the newer of two sources. 2. A mid-batch throw can leave the overlay half-swept. Neither reproduced as a live defect under the current UI's invariants. |
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
Claude review - no findings for
|
|
Re-triggering (empty commit Two ways to clear it: merge current For context, this is a process red rather than a code one — #4153 is open and |
…read' into fix-4153-drawing2d-markup-ui
The header comment on drawing-markup-save.ts asserted that save -> export -> reopen -> restore "works" unconditionally. That was false when written: the reader read the parsed geometry back verbatim, which round-tripped correctly for an identity storey placement but not otherwise, and only the reader's later placement-chain composition and plan-Y-negation inversion (the fix for issue #4153's reader side) made the general case true. State that dependency explicitly instead of asserting the result as fact, and correct the adjacent claim that the reader mirrors the writer by reading points "straight off the parsed geometry" -- it no longer does. No behavior change; comment-only.
|
Restacked onto #4167's current head (2026-09-08). This branch was pinned 4 commits behind What changed here:
Pushed to |
Claude review - no findings for
|
Closes #4153
Stack (three deep — merge LAST)
#4160(write side:IfcAnnotationbuilders in@ifc-lite/create) →#4167(read side:readDrawingMarkupFromParseResult, now includingaf5e65b5c's reader-side placement fix — see below) → this PR (UI wiring). This PR's base branch isfix-4153-drawing2d-markup-ifc-read(#4167's head), which already includes #4160. Please merge #4160 and #4167 first; this one should merge last, retargeted tomainonce its base has landed.Restacked 2026-09-08. This branch was previously pinned 4 commits behind #4167's head and did not include
af5e65b5c("invert the annotation placement chain and plan-Y negation when reading drawing markup") — the reader-side fix that makes the round trip below actually correct on a non-identity storey placement. This push merges #4167's current head in, so this branch now carries that fix; the section below describing what round-trips has been corrected to match.No CI runs on this branch.
test.ymltriggers only onpull_request: branches: [main], and this branch targets #4167's branch, notmain. Every check below was run locally.What this adds
addDrawingMarkupToStorewith the current measurements/area annotations/text notes/revision clouds.IfcAnnotationentities are read back intoDrawing2DStatevia the existingreadDrawingMarkupFromParseResult, reusing the shared symbolic-annotation parse cache (no second WASM walk).Design decisions
Is it actually "saved"? No —
saveDrawingMarkupToModelwrites into the model'sStoreEditoroverlay only, the same overlayExportChangesButtonalready reads from viamarkModelsDirty. Nothing touches disk. The button's tooltip and every toast say so explicitly: "Save drawing markup into the model (overlay only — Export Changes writes it to a file)", and a successful save reads "Added N markup annotation(s) to the model — use Export Changes to save it to a file."Idempotence.
removeDrawingMarkupFromStoresweeps every overlay-onlyIfcAnnotationtagged with aDRAWING_MARKUP_OBJECTTYPEvalue (plus everything only it references — placement, polyline/points, representation) before every save, mirroringemit-spatial-zones.ts'sremoveSpatialZonessweep-and-replace. Pressing Save N times leaves exactly one copy of the current markup. Saving an empty drawing when a previous save exists clears it (rather than refusing with "nothing to save").The double-restore question (#4159). #4159 restores markup from
localStorage, keyed by content hash, and is a separate, actively-restructured branch this PR does not touch or build on. Since both this restore and #4159's write the four flat arrays as a full overwrite (never append), duplication is structurally impossible regardless of ordering — the only open question is which source wins when both would populate the same model. I picked never overwrite:tryRestoreDrawingMarkupchecks the arrays are still empty immediately before writing (checked twice — once up front, once again right beforesetState, since resolving the parse can take a tick), and backs off permanently for that model-session if something already populated them. Whichever source runs first wins; this restore never clobbers an existing value. I could not make the ordering itself deterministic against #4159 without touching its files, which the brief said to avoid — this is the defensible rule available without that, and is documented inuseDrawingMarkupRestoreOnLoad.ts's module doc for whoever finishes the resolution once both PRs are in the same tree.A bug this surfaced along the way
dataStore.entities.getObjectType()— the parser's batched columnar cache — only returns a value for entities the "lite" fast-parse path chose to batch-extractObjectTypefor (Group family, geometry, type objects).IfcAnnotationlands in the "other relevant products" bucket, which is only batch-extracted forGlobalId+Name. ReadingObjectTypeoff a lite-parsedIfcAnnotationthis way silently returns''every time — caught by a restore test against a real parsed fixture. Fixed by readingObjectTypeviaEntityExtractoroff the source bytes instead (readBaseObjectTypeindrawing-markup-restore.ts), the same conventionresolve-anchor.tsalready uses for other root attributes it can't trust the columnar cache for.A full-suite run also caught a second issue: mounting
useDrawingMarkupRestoreOnLoadunconditionally reached an existing component test (measure-parity.test.tsx) whose fixture seeds a deliberately partialIfcDataStorestub as the active model — constructing aStoreEditor(which walksentityIndex.byId) during a pure read crashed it. Fixed by splittingdrawing-markup-context.tsinto a read-only context (getDrawingMarkupReadContext, noStoreEditor) used by restore, and the full read+write context used by save — plus a defensivetry/catcharoundrestoreDrawingMarkupFromModelso an unexpected shape degrades to "not restored yet" rather than crashing the panel.Local verification (substituting for the absent CI)
pnpm typecheck— 0 errors, all 1767 test files across 48 packages in the typecheck program.pnpm lint(oxlint) — 0 errors; 5 pre-existing warnings elsewhere in the repo, unrelated to this change.node scripts/check-module-size.mjs— OK, 0 new files over 400 lines (Section2DPanel.tsxgrew by 9 net lines, from 1356 to 1365, against its recorded budget of 1367 — 2 lines of headroom left, budget untouched;Drawing2DCanvas.tsx, at 0 headroom, was not touched at all).node scripts/check-test-wiring.mjs— OK.node scripts/check-source-text-assertions.mjs— OK, 0 new.npx turbo test --filter=@ifc-lite/viewer(full suite, no cache, run on the restacked branch): 7174 pass, 0 fail, 6 pre-existing skips (7180 total across 1584 suites, 42/42 tasks) — one suite and one test more than the pre-restack count, fromaf5e65b5c's addeddrawing-markup-read-real-roundtrip.test.ts. Also ran directly and in isolation: 1 pass, 0 fail.Mutation testing (each mutation
grep/diff-confirmed applied to the file, then reverted and re-confirmed clean):saveDrawingMarkupToModel's write call (short-circuit return beforeaddDrawingMarkupToStore) → 4 of 7 save-side tests went RED (both "writes annotations" and "twice doesn't duplicate" suites).buildDrawingMarkupMetaLookup'sObjectTypeextraction (forcedreadBaseObjectTypeto always returnnull) → 3 of 9 restore-side tests went RED across the two new suites; re-run together with the save mutation after theStoreEditorrefactor showed 7 of the combined 16 new tests RED, confirming both wirings are still covered post-refactor.Tests added
apps/viewer/src/lib/drawing2d-markup/drawing-markup-save.test.ts— realIfcParser+StepExporterfixture: writes the expected taggedIfcAnnotation(test 1), survives export/re-parse, replaces rather than duplicates on a second save (test 4), clears on an empty re-save, and refuses cleanly for a missing model / model with no storey.apps/viewer/src/lib/drawing2d-markup/drawing-markup-restore.test.ts—buildDrawingMarkupMetaLookupagainst a real base-file fixture (not overlay-authored, unlike the existingdrawing-markup-read.test.tsfixtures) surfacingObjectType/Qto_IfcLiteMarkupcorrectly (test 2), an untagged annotation with real geometry is skipped (test 5), a model with no tagged annotations restores nothing without throwing (test 3), andrestoreDrawingMarkupFromModelreturnsnull(not empty) before a parse has landed.apps/viewer/src/hooks/useDrawingMarkupRestoreOnLoad.test.ts—tryRestoreDrawingMarkupcalled bare (noact(), since production calls it from a raw store subscription and a parse-cache callback, not a React event handler): never overwrites already-populated fields, backs off permanently once attempted, and (via a faked WASM worker reply, same techniqueuseSymbolicAnnotations.frameRace.test.tsuses) restores from a real parse without duplicating on a repeated call.Not done here / known limits
drawing-markup.ts) anchors eachIfcAnnotationunder the real storey placement and writes the caller's(x, y)verbatim — it does no section-cut reprojection, whichdrawing-markup.ts's own docs call out as a UI/wiring concern deliberately left to this action. On its own, reading those points back verbatim would only round-trip correctly when the storey chain is the identity. feat(viewer): translate tagged IfcAnnotation markup back into typed Drawing2DState (read side) #4167'saf5e65b5creader-side fix (toAuthoredPointindrawing-markup-read-placement.ts) composes the annotation's own placement chain and inverts the symbolic parser's plan-Y negation to recover the authored local point — that is what makes the round trip correct on a real, non-trivially-placed model, and this branch now includes it (see the restack note above).toAuthoredPoint) handles translation, any in-plane (about-Z) rotation, and a non-degenerate out-of-plane tilt by derivation, but the only real end-to-end round-trip test for drawing markup (drawing-markup-read-real-roundtrip.test.ts) runs againstbuilding-architecture.ifc, whose storey placement is translation-only — its own doc comment says so. The underlying placement-composition rotation math (composeWorldPlacement) is covered separately, but only by synthetic single-axis fixtures inworldPlacement.test.ts, not by a real rotated model, and not in combination with drawing markup at all. No fixture anywhere in this stack combines a non-identity rotation with a non-metre length unit. A rotated-chain and/or non-metre-unit round-trip fixture for drawing markup specifically would be a useful follow-up.Comment claiming this scope: #4153 (comment)