Filing findings
An inspection's deliverable isn't the point cloud itself — it's findings (where, what kind of damage, and what grade). oniyanma holds findings as data separate from the point cloud.
The 📍 Findings tool rail lets you file, list, and reply. The same commands are also reachable from window.oniyanma, the AI console, or MCP (the code on this page produces the same result through any of those entry points).
The type itself is data
Inspection forms differ by client and get revised. Hard-coding the items means editing the app every time a new project comes along.
So oniyanma holds the finding type (FindingType) as data. Define a type and it becomes a JSON Schema directly, showing up in the AI's tool definitions. That means a customer's own defined items can be filled in by the AI from that same day.
One type, damage, is built in by default — the minimal shape for bridge inspection.
| Field | Type | Content |
|---|---|---|
kind | enum (required) | crack / spalling & rebar exposure / water leak & efflorescence / corrosion / other |
grade | enum (required) | a / b / c / d / e (matches MLIT's judgment grades) |
size | measurement (mm) | crack width, spalling extent, etc. |
note | text | Notes |
photo | text | Photo number |
inspectedOn | date | Inspection date |
previous | link → Finding | The finding from the previous inspection (used for recurrence judgment on a 5-year cycle) |
This is an example. On a real project it's replaced with the client's own inspection form.
Defining a type
Field types come in 7 kinds: text / number / enum / boolean / date / link / measurement.
await oniyanma.execute('defineFindingType', {
def: {
id: 'crack',
label: 'Crack',
standard: 'Periodic Road Bridge Inspection Guidelines (H31.3)',
fields: [
{ id: 'width', label: 'Crack width', type: 'measurement', unit: 'mm', required: true },
{ id: 'length', label: 'Length', type: 'measurement', unit: 'm' },
{ id: 'grade', label: 'Grade', type: 'enum', required: true, options: ['a', 'b', 'c', 'd', 'e'] },
{ id: 'note', label: 'Notes', type: 'text', maxLength: 2000 },
],
},
})Reusing the same id updates it as a new version. A type definition carries version, provenance, and draft/published just like a finding does, so "draft a revision to the form, then switch over" is handled the same way.
List them with listFindingTypes, or get one as a JSON Schema with getFindingTypeSchema.
Filing one
await oniyanma.execute('createFinding', {
typeId: 'crack',
fields: { width: { value: 0.3, unit: 'mm' }, grade: 'c', note: 'Longitudinal, on the lower flange of the main girder' },
pin: [x, y, z], // real coordinates (m). Optional
})
// → { id: 'e_…', typeId: 'crack', version: 1, draft: true }fields is validated against the type definition. On a mismatch, an error listing which field failed and why comes back, so either the AI or a human can fix it directly.
pin is in real coordinates. A clicked point's coordinates show at the bottom left of the screen, so you can pick them up from there.
Pins on the point cloud, and the list
To file from the UI, select the 📍 Findings tool rail and click on the point cloud — an entry form opens right there (just pick a type and fill in the fields; the coordinate is filled in automatically).
Filed findings show up as pins on the point cloud, and also as a list in the side panel. Clicking a list item moves the camera to that pin, so "pick it up from the list, then go look at the spot" works directly. Every command behind the pins and the list is the exact same one shown above, so a finding filed from the UI is just as reachable from the AI or a script.
Draft and published
A filed finding starts as a draft. It's not visible to the client. publishFinding confirms it as the published version.
await oniyanma.execute('publishFinding', { id })Publishing is an explicit action, not a side effect of saving. Changing the content after publishing with updateFinding means the client keeps seeing the old version until it's published again (the list's hasUnpublishedChanges becomes true).
| Operation | Command |
|---|---|
Partial update (setting a value to null clears that field) | updateFinding |
List (draft by default / published / deleted) | listFindings |
| Publish / unpublish | publishFinding / unpublishFinding |
| Delete / restore | deleteFinding / restoreFinding |
Delete is a tombstone. The record stays and it just drops out of listings and the published surface, so restoreFinding brings it back (including any edits made while it was withdrawn). Calling it from a machine passes through a confirmation gate.
Exporting
exportFindings exports to CSV or GeoJSON. The default is published only — the form you can hand to the client. view: 'draft' gives an internal version that includes drafts too.
await oniyanma.execute('exportFindings') // published, CSV
await oniyanma.execute('exportFindings', { format: 'geojson', view: 'draft' })CSV columns are "common columns (id / version / updatedAt / actor / x,y,z) + the union of every value key that appeared". Files aren't split per type, because a single inspection normally mixes multiple types, and splitting them would turn cross-referencing into manual work. An empty cell means "that type has no such item".
To match an inspection report's forms (Forms 2/3), use exportFindingsForm23. It exports only findings filed under the bridge_form23 type (items conforming to Forms 2/3, defined via defineFindingType) as a CSV with columns fixed in the form's entry order — unlike the generic exportFindings, the column order comes from the form, not from the union of value keys that happened to appear. This is neither the electronic-delivery standard's XML nor the form itself (.xlsx), but a CSV meant to be pasted in.
Replying
A finding can carry replies (a thread). When there's a question about the grade, or you want to ask someone to check the site in person, you can leave a back-and-forth without touching the finding itself. In the UI, clicking a pin shows the finding and its replies side by side, and you can write a reply right there.
const c = await oniyanma.execute('commentFinding', { id, text: 'Please confirm which item in the guidelines this corresponds to' })
await oniyanma.execute('editComment', { id: c.id, text: 'Please confirm whether this matches item 3 of Form 2' })
await oniyanma.execute('listComments', { id })
// → [{ id, text, actor, createdAt, editedAt }, …] (oldest first)Like a finding, a reply can be edited after it's posted (from the reply's ⋯ menu in the UI). Editing the body is editComment (not updateFinding — a reply's body is the reserved key $text, and updateFinding only ever touches the type-definition's own fields, so it can't reach it). Withdrawing one uses the same deleteFinding as a finding, since a reply is an Entry too. An edited reply gets editedAt set (so it's readable that it wasn't silently rewritten — it stays null if untouched).
Replies themselves don't show up in listFindings's list or in exportFindings (so a client deliverable never mixes with internal/team back-and-forth). Each finding in listFindings carries a comments count, so you can tell which findings have discussion piling up just from the list. Replies to replies aren't allowed (to keep the conversation from nesting and becoming unfollowable — everything attaches to the parent finding).
Importing and triaging AI detections
The output of an external damage-detection AI (a model that detects things like cracks from photos) can be bulk-imported as draft findings. With photos and camera poses (COLMAP etc.) registered beforehand, a detection's 2D coordinates (bbox / polygon / representative point) are projected into real coordinates to place a pin.
await oniyanma.execute('importDetections', {
photoSetId: 'set1',
detections: [
{ photoId: 'IMG_001.jpg', typeId: 'crack', bbox: [120, 340, 60, 20], confidence: 0.83, sourceLabel: 'crack_v3' },
// …
],
})
// → { batchId: 'b_…', created: [...], count: 12, noIntersection: 0, total: 12 }Every item is type-checked and its photo pose confirmed before anything is created (if even one fails, none are). The import is grouped into a single batch, so if the schema or the projection settings turn out wrong, revertBatch withdraws the whole thing at once.
When the same damage appears in multiple photos (multi-view merge)
Detecting per photo means the same crack captured in several shots counts multiple times. Detections whose 3D positions are close are grouped as candidates, and confirmed ones are merged into one.
const { groups } = await oniyanma.execute('queryMergeCandidates', { radiusM: 0.5 })
await oniyanma.execute('mergeFindings', { ids: groups[0] }) // the survivor is auto-picked by highest confidence
await oniyanma.execute('unmergeFinding', { id }) // undo a mergeA merge isn't a deletion — it only marks $mergedInto — so the record stays even after it drops out of lists and reports, and can be individually undone at any time. Field values themselves aren't merged (confirming is always left to a human, to avoid "a number in the report silently changes").
The triage UI (working through thousands of items quickly)
A single project can produce thousands of detections, so there's a dedicated view in the ☑ Triage tool rail. It lays out a photo crop and the 3D position for one item at a time, ordered by confidence, and can be worked through with the keyboard alone.
| Key | Action |
|---|---|
A | Accept (confirmFinding) and move to the next |
R | Reject (rejectFinding) and move to the next |
M | Merge, if there are merge candidates, and move to the next |
Accepting and rejecting record who reviewed it, and when as provenance ($review). Rejecting isn't a deletion, so it drops out of the list and merge candidates but the record stays, and it isn't reversible with restoreFinding (to retract it, fix $review via updateFinding instead).
Referring to a detection with no name
To point at a finding by condition instead of by id — "that crack on that girder" — use queryFindings.
await oniyanma.execute('queryFindings', { typeId: 'crack', near: [x, y, z], maxDistance: 2 })
// → { results: [{ id, pin, distance, sourceLabel, confidence, reviewStatus }, …], consideredCount, withoutPin }You can narrow with any combination of typeId (damage category), sourceLabel (the detection AI's raw label, partial match), and near + maxDistance (proximity in real-coordinate meters); giving near returns results nearest first.
Sharing while collaborating
Findings live in a Y.Doc the app owns, and a sync provider is attached to it after joining a room. Because ownership points this direction, findings stay with you after you leave the room (putting them in the collaboration Doc would make them vanish on leaving).
Sync happens in a separate room (<room>:entries) from the edit log. Streaming two Y.Docs over one WebSocket would corrupt the other party's doc, since Yjs's sync messages don't distinguish which doc they belong to. The invite link stays one link — both sides derive the same room name by the same rule.
Findings filed locally before joining are merged in by the CRDT the instant you connect (the same id becomes one entry; different ids become a union). Unlike the edit log, there's no need to push a manual "bring what I had" step, because findings are a collection of Y.Maps keyed by id.
If two people edit different fields of the same finding at the same time, neither is lost (the smallest unit exchanged is a single field). Editing the same field at the same time is last-write-wins, but both sides always converge to the same value.
Current limits
Multi-view merging doesn't reconcile field values automatically. It only chooses which finding survives — it doesn't merge conflicting values (fields other than confidence, for example) even if they exist. Value handling is always left to a human, to avoid "a number in the report silently changes".
Triage's "Accept" doesn't change the finding itself. confirmFinding only records into $review; fixing the content is a separate updateFinding call.
There's no audit log yet. Who confirmed/rejected what and when is recorded in $review, but the full state sent to the AI or the full prompt text is a separate, unresolved concern.
Saving lives alongside the sidecar. Findings and type definitions go into the same <dataset>.oniyanma.json (version 2 onward) as non-destructive edits — the save path stays single while findings ride along. On reading it back, an id you already have locally is never overwritten (so loading never clobbers your work). → Saving and exporting