Skip to content

Add /import?src=<url>: a hand-off point for scanning apps - #720

Open
alxbouchard wants to merge 8 commits into
pascalorg:mainfrom
alxbouchard:import-from-url
Open

Add /import?src=<url>: a hand-off point for scanning apps#720
alxbouchard wants to merge 8 commits into
pascalorg:mainfrom
alxbouchard:import-from-url

Conversation

@alxbouchard

@alxbouchard alxbouchard commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What

A new /import?src=<https-url>[&name=<scene name>] page: an external tool — in our case an iOS LiDAR scanning app — hosts a build JSON at a URL and opens this page; the visitor reviews what the file contains and imports it as a new scene with one click.

Until now the only way to get a generated scene into the editor was dragging a file onto Load Build, which does not exist on mobile. With this page, any scan app can end its export flow with "Open in Pascal Editor".

How it works

  • The fetch happens client-side in the visitor's browser — the same trust model as dropping a file on Load Build. The host must allow CORS; no server ever fetches the URL, so there is no SSRF surface.
  • The file runs through the same validateBuildJson pre-flight as Load Build, and the page shows the node counts, floor area, warnings and errors before anything happens.
  • Only an explicit click creates the scene, through the regular POST /api/scenes route — so auth, origin checks and apiGraphSchema validation (including the AssetUrl allowlist) all apply unchanged.
  • src accepts https only (http for localhost during development), rejects embedded credentials, and caps the document at 25 MB. URL validation lives in lib/import-src.ts with unit tests.

Tested

  • bun test lib: 41 pass (6 new)
  • bun run check-types, biome check: clean
  • End to end against a real scan: a RoomPlan-captured apartment (31 walls, 27 items, slab, scene materials) served from a CORS-enabled URL → review page → one click → scene opens in the editor with furniture and per-item slot materials rendering correctly.

Why we built it

We build A3 Atlas Scanner, an iOS field tool that captures homes with RoomPlan and already exports your {nodes, rootNodeIds, materials} graph (catalog items scaled to measured dimensions, measured colors as scene materials, IFC alongside). This page is the missing link that turns every scan into a one-tap Pascal scene. Happy to adjust anything to fit the project's conventions.

🤖 Generated with Claude Code


Note

Medium Risk
New entry point for creating scenes from arbitrary HTTPS URLs, but creation still goes through existing authenticated /api/scenes validation; primary risk is user-supplied remote JSON in the browser, not server SSRF.

Overview
Adds /import?src=<url>[&name=…] so scanning apps and other tools can open the editor with a hosted build JSON instead of relying on desktop Load Build drag-and-drop (not available on mobile).

The browser fetches and parses the file client-side (CORS required; no server-side fetch), validates it with validateBuildJson like Load Build, shows stats/errors/warnings and an editable scene name, and only on confirm POSTs to /api/scenes then navigates to the new scene. Failed creates stay on the review screen with Try again; fetch/create paths enforce 10 MB (byte-accurate via Blob), abort on unmount, and a ref guard against double-submit.

parseImportSrc in lib/import-src.ts restricts src to absolute https ( http only on localhost), blocks credentials and dangerous schemes, with unit tests.

Reviewed by Cursor Bugbot for commit ce2917a. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread apps/editor/app/import/import-client.tsx Outdated
Comment thread apps/editor/app/import/import-client.tsx
Comment thread apps/editor/app/import/import-client.tsx
@alxbouchard

Copy link
Copy Markdown
Contributor Author

Follow-up commit: while testing the import end to end I found that validateBuildJson drops the top-level materials table, so every scene:<id> slot ref in an imported file pointed at a material that no longer existed — custom finishes silently reverted to defaults on the existing Load Build path too, not just on this new page. The second commit carries materials through ParsedBuildJson (each entry validated individually, invalid ones skipped with a warning) and hands them to setScene, which already supported them. Covered by unit tests; verified visually with a scan whose furniture uses per-item scene materials.

Comment thread apps/editor/lib/import-src.ts Outdated
@alxbouchard

Copy link
Copy Markdown
Contributor Author

Addressed the Bugbot review (it ran against the first commit, 53b688c):

  • Import drops scene materials — this was real, and deeper than the new page: validateBuildJson dropped the top-level materials table for the existing Load Build path too. Fixed in 81715bc (materials carried through ParsedBuildJson, entries validated individually, handed to setScene).
  • Aborted fetch shown as CORS / stale review across src changes — both fixed in the latest commit: the effect resets to fetching on every src change, and a cancelled run can no longer overwrite newer state or surface its abort as an error.

@alxbouchard

Copy link
Copy Markdown
Contributor Author

Third Bugbot point addressed: MAX_IMPORT_BYTES now matches the scene store's 10 MB limit (DEFAULT_MAX_SCENE_BYTES) so a file can't pass review and then 413 on create — and a 413 now gets its own message instead of the generic failure.

Comment thread apps/editor/app/import/import-client.tsx
@alxbouchard

Copy link
Copy Markdown
Contributor Author

Double-tap point addressed: a synchronous useRef guard now blocks re-entry into the create call (released in a finally so a failed create can be retried) — the state-based check alone could indeed race the re-render, especially on the mobile hand-off this page exists for.

Comment thread apps/editor/app/import/import-client.tsx
@alxbouchard

Copy link
Copy Markdown
Contributor Author

Fifth point addressed: a failed create no longer unmounts the review — the validated graph stays on screen with the error shown inline and the button relabelled "Try again", so a short-lived src URL never has to be re-fetched just to retry.

@Aymericr Aymericr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and genuinely thanks for how you've handled the review rounds — the escalation from "import drops materials" to "validateBuildJson drops materials on the Load Build path too" is a real bug you found for us, and it's the strongest part of the PR. I confirmed it at main: handleConfirmImport passes only installedPlugins to setScene.

A few things before this can land.

One blocker: apps/editor/lib/import-src.test.ts imports from vitest, but vitest isn't a dependency anywhere in the repo, and every other test under apps/editor/lib/ uses bun:test (apps/editor's test script is bun test lib). That file can't resolve its imports, so the "41 pass" in the description can't have run. Please switch it to bun:test and re-run.

Then:

  • The size cap uses text.length, which is UTF-16 code units, not bytes — a graph with non-ASCII names can pass review and still 413 at the store, which is the thing the 10 MB alignment commit was for. new Blob([text]).size covers it.
  • validateBuildJson now stores SceneMaterial.safeParse().data, so it injects defaults and drops unknown keys. apps/editor/lib/graph-schema.ts deliberately does the opposite for exactly that reason (there's a comment). I'm fine with normalizing on a client-side import, but let's make it an explicit choice.
  • In the settings panel, the param is widened to Record<string, unknown> and then cast back. ParsedBuildJson is the right type now — please use it directly.
  • Please drop the bun.lock changes; the added sha512 hashes on the github: deps are a bun regeneration artifact, not part of this change.

On scope: I'd like to take the materials fix on its own, because it fixes a live bug on Load Build and shouldn't wait on the rest. Would you split it into a separate PR? I'll merge that quickly.

On the import page itself, one thing to sort out first. editor.pascal.app is a separate hosted app from apps/editor, so this page would only ship on the standalone editor, not the hosted one. And we just landed @pascal-app/capture-protocol (#713), which is the versioned, extensible hand-off format for exactly this use case — manifests, locators, capture sources. I don't think these are the same thing (yours is an already-converted build graph becoming a new scene; #713 is a capture session rendered as scan layers), and I can see wanting both. But I'd rather we agree on where the seam sits before adding a second entry point. Have a look at wiki/architecture/capture-runtime.md and tell me whether your tool would be better served by emitting a capture-protocol manifest, or whether the build-JSON path is genuinely the one you need — happy to talk it through.

Comment thread packages/core/src/validation/validate-build-json.ts
Comment thread apps/editor/app/import/import-client.tsx
@Aymericr

Copy link
Copy Markdown
Contributor

Verified the new head — the code asks are done. import-src.test.ts:1 is on bun:test, the cap at import-client.tsx:83 measures bytes via new Blob([text]).size, and the lockfile noise is gone. Thanks also for #729 — that's exactly the split I wanted, and the DELIBERATE: block in validate-build-json.ts is the explicit choice I asked for; the store-only-understands-schema-shaped-materials argument convinces me. I left one ask over there (Save Build should export the materials table it now imports — same file, closes the round-trip); once that lands I'll merge it.

Three things left here, only one of them substantive:

  1. The seam question is still the gate. Carry scene materials through Load Build #729's description says it's "being addressed on Add /import?src=<url>: a hand-off point for scanning apps #720", but there's no answer yet. Before this page lands I want your read on wiki/architecture/capture-runtime.md: does your tool need the build-JSON path, or would a capture-protocol manifest serve it better? Happy to talk it through — but I won't add a second entry point until we've agreed where it sits.
  2. After Carry scene materials through Load Build #729 merges, rebase this branch on main and drop the duplicated validate-build-json.* / settings-panel hunks — this branch still carries the pre-Carry scene materials through Load Build #729 versions (including the Record<string, unknown> cast at settings-panel/index.tsx:294-306 that Carry scene materials through Load Build #729 already fixes properly).
  3. quality is red on formatting only: biome wants two hunks in import-client.tsx collapsed (the createError: ternary around line 136 and the {phase.createError && …} JSX around line 207). biome check --write and repush.

Two non-blocking notes while you're in there. Bugbot's stale-sceneName point is real but tiny — key={params.src} on <ImportClient> in page.tsx fixes it and makes your manual phase reset redundant. And response.text() buffers the whole body before the Blob check, so a server that lies about content-length can stream past the cap into the visitor's tab; reading via body.getReader() and aborting past MAX_IMPORT_BYTES would close that — fine as a follow-up.

I checked the re-entry guard and retry path myself: creating.current is set synchronously before the first await and released in finally, and a failed create keeps result in state so "Try again" never refetches src. Both correct.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4473b86. Configure here.

Comment thread apps/editor/app/import/import-client.tsx
@alxbouchard

Copy link
Copy Markdown
Contributor Author

Read through wiki/architecture/capture-runtime.md — here's my answer to the seam question.

They're two different payloads, and this tool ultimately wants both — but for this hand-off, build JSON is the right seam.

What A3 Atlas sends through /import?src= is not capture data: it's the interpreted scene — walls, openings, fixtures, measured materials — already converted to Pascal's graph on the scanner side, the same shape Save/Load Build round-trips. The whole point of the hand-off is that the recipient lands in an editable scene: repaint a wall, move a cabinet, keep working. A capture-protocol manifest can't carry that without becoming a second scene graph — which capture-runtime.md rules out in its first sentence ("not a second scene graph"). Forcing the interpreted graph through the manifest would either lose editability or bloat the protocol with scene semantics it was designed to stay out of.

The capture protocol is the right seam for the evidence: the RoomPlan mesh, the camera track (device motion), the scan video, a point cloud. Atlas already produces those artifacts, and the natural integration is exactly the one the doc describes — the exported build JSON later carries a scan node whose captureSession locator points at an Atlas-hosted manifest, so the editable graph and the raw capture each ride the seam they were designed for. Nothing about /import blocks that; the manifest URL just becomes part of the same file when we get there.

So my read: /import isn't a second scene entry point competing with capture — it's the missing HTTP entry for the object the editor already imports via the file picker. Happy to sketch the scan-node + manifest step as a follow-up issue if that helps settle where it sits.

@Aymericr

Copy link
Copy Markdown
Contributor

That settles it — and it's the right answer. The distinction you drew is the one the doc was written to protect: the manifest carries evidence, never a second scene graph, and what Atlas hands off here is the interpreted scene — the same object the file picker already imports. /import as the missing HTTP entry for that object, with the capture manifest arriving later as a scan node's captureSession locator in the same file, is exactly where I wanted the seam to sit. Yes to the follow-up issue: sketch the scan-node + manifest step there and reference this thread.

So the gate is lifted. What's left is mechanical:

  1. Carry scene materials through Load Build #729 has one remaining ask (Save Build should export the materials table it now imports) — push that, I merge it.
  2. Rebase this branch to drop the duplicated validate-build-json.* / settings-panel hunks.
  3. The two biome hunks in import-client.tsx.

Then this lands.

@alxbouchard

Copy link
Copy Markdown
Contributor Author

Status on the three: the seam answer is posted above; the biome hunks are collapsed and repushed (4473b86); the rebase is queued for right after #729 merges — this branch still carries the pre-#729 hunks and they'll drop then. Both non-blocking notes taken: key={params.src} is in (the manual phase reset went with it), and Bugbot's schemaIssues gap is fixed in 988acec. The streaming cap via body.getReader() I'll take as the follow-up you suggested.

On the hosted-vs-standalone point: for Atlas's real scenario the recipient clicks a link and lands in a browser, so we ultimately need this on editor.pascal.app — nobody installs a local editor to open a shared scan. I read this PR as the reference implementation on the standalone app; whether and when the hosted app adopts the same entry is your call once the seam is settled, and I'm happy to keep Atlas pointing at build-JSON files that work on both.

Aymericr pushed a commit that referenced this pull request Aug 31, 2026
* Carry scene materials through Load Build

validateBuildJson dropped the top-level materials table, so every
scene:<id> slot ref in an imported file pointed at a material that no
longer existed — custom finishes silently reverted to defaults on Load
Build. ParsedBuildJson now carries materials, each entry validated
individually (a bad material never takes the import down, it is
skipped with a warning), and handleConfirmImport hands them to
setScene, whose extra.materials support already existed.

Normalization here is DELIBERATE and documented in-line:
safeParse().data injects defaults and drops unknown keys — the
opposite of apiGraphSchema's preserve-unknowns stance — because import
feeds the live scene store, which only understands schema-shaped
materials.

Split out of #720 at the maintainer's request.

* Save Build exports the materials table it now imports

Review follow-up (#729): paint a finish, Save Build, Load Build that
file — the finish reverted to default because handleSaveBuild still
exported only { nodes, rootNodeIds, installedPlugins }. Materials ride
along now, closing the round-trip this PR opened on the import side.

Also names the skipped ids in the invalid_materials warning: the
audience is hand-edited files, and a bare count leaves nothing to
repair by.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@Aymericr

Copy link
Copy Markdown
Contributor

#729 is merged (with your Save Build follow-up — verified and squashed). This branch now conflicts with main on the duplicated validate-build-json.* / settings-panel hunks, as expected; I tried updating the branch from my side but it needs a real rebase. Drop those hunks in favor of main's copies, and with CI green this merges — the seam question is settled, the code asks are done, and the biome/key={src} fixes are in.

@alxbouchard

Copy link
Copy Markdown
Contributor Author

To make the use case concrete — the end state Atlas wants is one button: "Open in Pascal Editor" on a completed scan. It opens this import page with src pointing at the hosted build JSON; signed in, the visitor chooses to import into a project or create a new one; signed out, they're invited to create an account first. That's the whole story — the scanning app stays a scanner, the editing lives with you. This PR is the first brick of that flow on the standalone app; the account/project half is naturally yours and only makes sense if you adopt the entry on the hosted side.

alxbouchard and others added 8 commits August 31, 2026 00:36
A scanning app (or any external tool) can now open
editor.pascal.app/import?src=<https-url> to hand a build JSON to the
editor. The fetch happens client-side in the visitor's browser (same
trust model as dropping a file on Load Build; the host must allow
CORS), the file runs through the same validateBuildJson pre-flight,
the visitor reviews the contents, and only an explicit click creates
the scene through the regular POST /api/scenes route — so auth,
origin checks and apiGraphSchema validation all apply unchanged.

src accepts https only (http for localhost during development), no
embedded credentials, 25 MB cap. Unit tests for the URL validation.
Review feedback (Bugbot): a superseded or aborted fetch could
overwrite a newer state — including surfacing the cleanup abort as a
CORS error — and a src change left the previous review (and its
Import button) live against the old file. The effect now resets to
'fetching' on every src change and every state update from a
cancelled run is ignored.
Review feedback (Bugbot): MAX_IMPORT_BYTES was 25 MB while the sqlite
scene store rejects graphs over DEFAULT_MAX_SCENE_BYTES (10 MB) — a
file could pass review then fail POST /api/scenes with a 413 shown as
a generic error. The cap now matches the store's limit, and a 413 gets
its own explanation.
Review feedback (Bugbot): a second tap on Import could fire before
React re-rendered into 'creating', creating two scenes and racing the
redirect. A synchronous useRef guard now blocks re-entry; it is
released in a finally so a failed create can be retried.
Review feedback (Bugbot): a failed POST switched to the error phase,
unmounting the review and the validated graph — nothing left to retry,
and refreshing re-fetches a src URL that may be short-lived. A create
failure now stays in the review phase with the error shown inline and
the button relabelled 'Try again'.
- import-src.test.ts now imports from bun:test like every other test
  under apps/editor/lib (vitest is not a repo dependency — the bun
  runner shimmed the import, which is why the suite did run, but the
  file was wrong and the description should have said bun test).
- The size cap measures real bytes via Blob, not UTF-16 code units —
  non-ASCII names could otherwise pass review and still 413.
- bun.lock restored to main (the sha512 additions were a bun
  regeneration artifact, not part of this change).
Two hunks collapsed per biome (createError ternary, createError JSX).
Bugbot's stale-sceneName note taken: the page keys <ImportClient> by
src, so a new file is a new mount and no state leaks between files —
the manual phase reset inside the effect is gone with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bugbot: the shared validateBuildJson error says "see details below",
but the page listed only errors and warnings — a blocked import had no
per-node path or message. Same data Load Build already shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants